24157 lines
905 KiB
TypeScript
24157 lines
905 KiB
TypeScript
import {
|
|
useEffect,
|
|
useRef,
|
|
useState,
|
|
type CSSProperties,
|
|
type ChangeEvent,
|
|
type MouseEvent as ReactMouseEvent,
|
|
type KeyboardEvent as ReactKeyboardEvent,
|
|
type PointerEvent as ReactPointerEvent
|
|
} from "react";
|
|
|
|
import { createCreateBoxBrushCommand } from "../commands/create-box-brush-command";
|
|
import { createCreateConeBrushCommand } from "../commands/create-cone-brush-command";
|
|
import { createCreateRadialPrismBrushCommand } from "../commands/create-radial-prism-brush-command";
|
|
import { createCreateSceneCommand } from "../commands/create-scene-command";
|
|
import { createCreateTorusBrushCommand } from "../commands/create-torus-brush-command";
|
|
import { createCreateWedgeBrushCommand } from "../commands/create-wedge-brush-command";
|
|
import { createDeleteBoxBrushCommand } from "../commands/delete-box-brush-command";
|
|
import { createDeleteEntityCommand } from "../commands/delete-entity-command";
|
|
import { createDeleteProjectAssetCommand } from "../commands/delete-project-asset-command";
|
|
import { createDeleteSelectionCommand } from "../commands/delete-selection-command";
|
|
import { createDeleteTerrainCommand } from "../commands/delete-terrain-command";
|
|
import { createDuplicateSelectionCommand } from "../commands/duplicate-selection-command";
|
|
import { createImportAudioAssetCommand } from "../commands/import-audio-asset-command";
|
|
import { createImportBackgroundImageAssetCommand } from "../commands/import-background-image-asset-command";
|
|
import { createImportModelAssetCommand } from "../commands/import-model-asset-command";
|
|
import { createAddPathPointCommand } from "../commands/add-path-point-command";
|
|
import { createDeletePathCommand } from "../commands/delete-path-command";
|
|
import { createDeletePathPointCommand } from "../commands/delete-path-point-command";
|
|
import { createDeleteModelInstanceCommand } from "../commands/delete-model-instance-command";
|
|
import { createCommitTransformSessionCommand } from "../commands/commit-transform-session-command";
|
|
import { createMoveBoxBrushCommand } from "../commands/move-box-brush-command";
|
|
import { createRotateBoxBrushCommand } from "../commands/rotate-box-brush-command";
|
|
import { createResizeBoxBrushCommand } from "../commands/resize-box-brush-command";
|
|
import { createSetBoxBrushAllFaceMaterialsCommand } from "../commands/set-box-brush-all-face-materials-command";
|
|
import { createSetBoxBrushFaceMaterialCommand } from "../commands/set-box-brush-face-material-command";
|
|
import { createSetBoxBrushAuthoredStateCommand } from "../commands/set-box-brush-authored-state-command";
|
|
import { createSetBoxBrushNameCommand } from "../commands/set-box-brush-name-command";
|
|
import { createSetBoxBrushVolumeSettingsCommand } from "../commands/set-box-brush-volume-settings-command";
|
|
import { createSetEntityAuthoredStateCommand } from "../commands/set-entity-authored-state-command";
|
|
import { createSetEntityNameCommand } from "../commands/set-entity-name-command";
|
|
import { createSetBoxBrushFaceUvStateCommand } from "../commands/set-box-brush-face-uv-state-command";
|
|
import { createUpdateBoxBrushAllFaceUvsCommand } from "../commands/update-box-brush-all-face-uvs-command";
|
|
import { createSetActiveSceneCommand } from "../commands/set-active-scene-command";
|
|
import { createDeleteInteractionLinkCommand } from "../commands/delete-interaction-link-command";
|
|
import { createSetModelInstanceAuthoredStateCommand } from "../commands/set-model-instance-authored-state-command";
|
|
import { createSetModelInstanceNameCommand } from "../commands/set-model-instance-name-command";
|
|
import { createSetPathAuthoredStateCommand } from "../commands/set-path-authored-state-command";
|
|
import { createSetPathNameCommand } from "../commands/set-path-name-command";
|
|
import { createSetProjectNameCommand } from "../commands/set-project-name-command";
|
|
import { createSetProjectSchedulerCommand } from "../commands/set-project-scheduler-command";
|
|
import { createSetProjectSequencerCommand } from "../commands/set-project-sequencer-command";
|
|
import { createSetProjectSequencesCommand } from "../commands/set-project-sequences-command";
|
|
import { createSetProjectTimeSettingsCommand } from "../commands/set-project-time-settings-command";
|
|
import { createSetSceneLoadingScreenCommand } from "../commands/set-scene-loading-screen-command";
|
|
import { createSetSceneNameCommand } from "../commands/set-scene-name-command";
|
|
import { createSetWorldSettingsCommand } from "../commands/set-world-settings-command";
|
|
import { createUpsertEntityCommand } from "../commands/upsert-entity-command";
|
|
import { createUpsertModelInstanceCommand } from "../commands/upsert-model-instance-command";
|
|
import { createUpsertPathCommand } from "../commands/upsert-path-command";
|
|
import { createUpsertTerrainCommand } from "../commands/upsert-terrain-command";
|
|
import { createUpsertInteractionLinkCommand } from "../commands/upsert-interaction-link-command";
|
|
import {
|
|
applySameKindSelectionClick,
|
|
getSelectedBrushEdgeId,
|
|
getSelectedBrushFaceId,
|
|
getSelectedBrushVertexId,
|
|
getSelectionDefaultActiveId,
|
|
getSingleSelectedBrushId,
|
|
getSingleSelectedEntityId,
|
|
getSingleSelectedModelInstanceId,
|
|
getSingleSelectedPathOwnerId,
|
|
getSingleSelectedPathPoint,
|
|
getSingleSelectedTerrainId,
|
|
isBrushFaceSelected,
|
|
isBrushSelected,
|
|
isPathPointSelected,
|
|
isPathSelected,
|
|
isSelectionActiveId,
|
|
isTerrainSelected,
|
|
resolveSelectionActiveId,
|
|
type EditorSelection
|
|
} from "../core/selection";
|
|
import {
|
|
clampTerrainBrushFalloff,
|
|
clampTerrainPaintLayerIndex,
|
|
clampTerrainBrushRadius,
|
|
clampTerrainBrushStrength,
|
|
createDefaultTerrainBrushSettings,
|
|
getTerrainBrushToolLabel,
|
|
type ArmedTerrainBrushState,
|
|
type TerrainBrushStrokeCommit,
|
|
type TerrainBrushTool
|
|
} from "../core/terrain-brush";
|
|
import {
|
|
createTransformSession,
|
|
doesTransformSessionChangeTarget,
|
|
getTransformAxisLabel,
|
|
getTransformAxisSpaceLabel,
|
|
getTransformOperationLabel,
|
|
getTransformTargetLabel,
|
|
resolveTransformTarget,
|
|
supportsLocalTransformAxisConstraint,
|
|
supportsTransformAxisConstraint,
|
|
supportsTransformOperation,
|
|
supportsTransformSurfaceSnapTarget,
|
|
type ActiveTransformSession,
|
|
type TransformAxis,
|
|
type TransformOperation,
|
|
type TransformSessionSource
|
|
} from "../core/transform-session";
|
|
import type { Vec2, Vec3 } from "../core/vector";
|
|
import {
|
|
MODEL_INSTANCE_COLLISION_MODES,
|
|
areModelInstancesEqual,
|
|
createModelInstance,
|
|
createModelInstancePlacementPosition,
|
|
DEFAULT_MODEL_INSTANCE_POSITION,
|
|
DEFAULT_MODEL_INSTANCE_ROTATION_DEGREES,
|
|
DEFAULT_MODEL_INSTANCE_SCALE,
|
|
normalizeModelInstanceName,
|
|
type ModelInstance,
|
|
type ModelInstanceCollisionMode
|
|
} from "../assets/model-instances";
|
|
import {
|
|
getModelInstanceDisplayLabelById,
|
|
getSortedModelInstanceDisplayLabels
|
|
} from "../assets/model-instance-labels";
|
|
import {
|
|
importAudioAssetFromFile,
|
|
loadAudioAssetFromStorage,
|
|
type LoadedAudioAsset
|
|
} from "../assets/audio-assets";
|
|
import {
|
|
importModelAssetFromFile,
|
|
importModelAssetFromFiles,
|
|
loadModelAssetFromStorage,
|
|
disposeModelTemplate,
|
|
type ImportedModelAssetResult,
|
|
type LoadedModelAsset
|
|
} from "../assets/gltf-model-import";
|
|
import {
|
|
importBackgroundImageAssetFromFile,
|
|
loadImageAssetFromStorage,
|
|
disposeLoadedImageAsset,
|
|
type ImportedImageAssetResult,
|
|
type LoadedImageAsset
|
|
} from "../assets/image-assets";
|
|
import type {
|
|
AudioAssetRecord,
|
|
ImageAssetRecord,
|
|
ModelAssetRecord,
|
|
ProjectAssetRecord
|
|
} from "../assets/project-assets";
|
|
import { getProjectAssetKindLabel } from "../assets/project-assets";
|
|
import {
|
|
getWhiteboxSelectionModeLabel,
|
|
type WhiteboxSelectionMode
|
|
} from "../core/whitebox-selection-mode";
|
|
import {
|
|
BOX_BRUSH_LIGHT_FALLOFF_MODES,
|
|
BOX_BRUSH_VOLUME_MODES,
|
|
DEFAULT_BOX_BRUSH_WATER_FOAM_CONTACT_LIMIT,
|
|
DEFAULT_BOX_BRUSH_CENTER,
|
|
DEFAULT_BOX_BRUSH_ROTATION_DEGREES,
|
|
DEFAULT_BOX_BRUSH_SIZE,
|
|
DEFAULT_CONE_SIDE_COUNT,
|
|
DEFAULT_TORUS_MAJOR_SEGMENT_COUNT,
|
|
DEFAULT_TORUS_TUBE_SEGMENT_COUNT,
|
|
MAX_BOX_BRUSH_WATER_FOAM_CONTACT_LIMIT,
|
|
createDefaultBoxBrushLightSettings,
|
|
createDefaultFaceUvState,
|
|
normalizeBrushName,
|
|
type Brush,
|
|
type BoxBrush,
|
|
type BoxBrushLightFalloffMode,
|
|
type BoxBrushVolumeMode,
|
|
type FaceUvRotationQuarterTurns,
|
|
type FaceUvState
|
|
} from "../document/brushes";
|
|
import {
|
|
ADVANCED_RENDERING_WATER_REFLECTION_MODES,
|
|
BOX_VOLUME_RENDER_PATHS,
|
|
ADVANCED_RENDERING_SHADOW_MAP_SIZES,
|
|
ADVANCED_RENDERING_SHADOW_TYPES,
|
|
ADVANCED_RENDERING_TONE_MAPPING_MODES,
|
|
areWorldSettingsEqual,
|
|
changeWorldBackgroundMode,
|
|
cloneWorldSettings,
|
|
DEFAULT_NIGHT_IMAGE_ENVIRONMENT_INTENSITY,
|
|
DEFAULT_TIME_PHASE_IMAGE_ENVIRONMENT_INTENSITY,
|
|
resolveWorldCelestialOrbitPeakDirection,
|
|
syncWorldShaderSkyDayGradientToBackground,
|
|
type WorldBackgroundMode,
|
|
type WorldBackgroundSettings,
|
|
type AdvancedRenderingSettings,
|
|
type BoxVolumeRenderPath,
|
|
type AdvancedRenderingWaterReflectionMode,
|
|
type AdvancedRenderingShadowMapSize,
|
|
type AdvancedRenderingShadowType,
|
|
type AdvancedRenderingToneMappingMode,
|
|
type WorldShaderSkySettings,
|
|
type WorldSettings
|
|
} from "../document/world-settings";
|
|
import {
|
|
areSceneLoadingScreenSettingsEqual,
|
|
cloneSceneLoadingScreenSettings,
|
|
createSceneDocumentFromProject,
|
|
DEFAULT_PROJECT_NAME,
|
|
type SceneLoadingScreenSettings
|
|
} from "../document/scene-document";
|
|
import {
|
|
MIN_SCENE_PATH_POINT_COUNT,
|
|
areScenePathsEqual,
|
|
createAppendedScenePathPoint,
|
|
createScenePath,
|
|
getScenePathLabel,
|
|
getScenePathPointIndex,
|
|
getScenePaths,
|
|
normalizeScenePathName,
|
|
type ScenePath,
|
|
type ScenePathPoint
|
|
} from "../document/paths";
|
|
import {
|
|
areTerrainsEqual,
|
|
createTerrain,
|
|
getTerrainBounds,
|
|
getTerrainFootprintDepth,
|
|
getTerrainFootprintWidth,
|
|
getTerrainKindLabel,
|
|
getTerrainLayerLabel,
|
|
getTerrains,
|
|
MIN_TERRAIN_SAMPLE_COUNT,
|
|
resizeTerrainGrid,
|
|
TERRAIN_LAYER_COUNT,
|
|
type Terrain
|
|
} from "../document/terrains";
|
|
import {
|
|
areProjectTimeSettingsEqual,
|
|
cloneProjectTimeSettings,
|
|
formatTimeOfDayHours,
|
|
normalizeTimeOfDayHours,
|
|
type ProjectTimeSettings
|
|
} from "../document/project-time-settings";
|
|
import {
|
|
formatSceneDiagnosticSummary,
|
|
validateProjectDocument,
|
|
validateSceneDocument
|
|
} from "../document/scene-document-validation";
|
|
import {
|
|
cloneProjectAssetStorageRecord,
|
|
getBrowserProjectAssetStorageAccess,
|
|
type ProjectAssetStorage,
|
|
type ProjectAssetStorageRecord
|
|
} from "../assets/project-asset-storage";
|
|
import {
|
|
DEFAULT_GRID_SIZE,
|
|
snapPositiveSizeToGrid,
|
|
snapVec3ToGrid
|
|
} from "../geometry/grid-snapping";
|
|
import {
|
|
createFitToFaceBoxBrushFaceUvState,
|
|
createFitToMaterialTileBoxBrushFaceUvState
|
|
} from "../geometry/box-face-uvs";
|
|
import {
|
|
getBrushDefaultName,
|
|
getBrushEdgeLabel,
|
|
getBrushFaceIds,
|
|
getBrushFaceLabel,
|
|
getBrushVertexLabel
|
|
} from "../geometry/whitebox-topology";
|
|
import {
|
|
DEFAULT_CAMERA_RIG_DEFAULT_ACTIVE,
|
|
DEFAULT_CAMERA_RIG_LOOK_AROUND_ENABLED,
|
|
DEFAULT_CAMERA_RIG_LOOK_AROUND_PITCH_LIMIT_DEGREES,
|
|
DEFAULT_CAMERA_RIG_LOOK_AROUND_RECENTER_SPEED,
|
|
DEFAULT_CAMERA_RIG_LOOK_AROUND_YAW_LIMIT_DEGREES,
|
|
DEFAULT_CAMERA_RIG_PRIORITY,
|
|
DEFAULT_CAMERA_RIG_RAIL_END_PROGRESS,
|
|
DEFAULT_CAMERA_RIG_RAIL_PLACEMENT_MODE,
|
|
DEFAULT_CAMERA_RIG_RAIL_START_PROGRESS,
|
|
DEFAULT_CAMERA_RIG_TARGET_OFFSET,
|
|
DEFAULT_CAMERA_RIG_TRACK_END_POINT,
|
|
DEFAULT_CAMERA_RIG_TRACK_START_POINT,
|
|
DEFAULT_CAMERA_RIG_TRANSITION_DURATION_SECONDS,
|
|
DEFAULT_CAMERA_RIG_TRANSITION_MODE,
|
|
DEFAULT_ENTITY_POSITION,
|
|
DEFAULT_INTERACTABLE_PROMPT,
|
|
DEFAULT_INTERACTABLE_RADIUS,
|
|
DEFAULT_NPC_MODEL_ASSET_ID,
|
|
DEFAULT_NPC_YAW_DEGREES,
|
|
DEFAULT_POINT_LIGHT_COLOR_HEX,
|
|
DEFAULT_POINT_LIGHT_DISTANCE,
|
|
DEFAULT_POINT_LIGHT_INTENSITY,
|
|
DEFAULT_PLAYER_START_BOX_SIZE,
|
|
DEFAULT_PLAYER_START_CAPSULE_HEIGHT,
|
|
DEFAULT_PLAYER_START_CAPSULE_RADIUS,
|
|
DEFAULT_PLAYER_START_EYE_HEIGHT,
|
|
DEFAULT_PLAYER_START_ALLOW_LOOK_INPUT_TARGET_SWITCH as DEFAULT_PLAYER_START_ALLOW_LOOK_INPUT_TARGET_SWITCH_VALUE,
|
|
DEFAULT_PLAYER_START_INTERACTION_ANGLE_DEGREES,
|
|
DEFAULT_PLAYER_START_INTERACTION_REACH_METERS,
|
|
DEFAULT_PLAYER_START_NAVIGATION_MODE,
|
|
DEFAULT_PLAYER_START_TARGET_BUTTON_CYCLES_ACTIVE_TARGET as DEFAULT_PLAYER_START_TARGET_BUTTON_CYCLES_ACTIVE_TARGET_VALUE,
|
|
PLAYER_START_MOVEMENT_TEMPLATE_KINDS,
|
|
DEFAULT_SOUND_EMITTER_AUDIO_ASSET_ID,
|
|
DEFAULT_SOUND_EMITTER_VOLUME,
|
|
DEFAULT_SCENE_ENTRY_YAW_DEGREES,
|
|
DEFAULT_SOUND_EMITTER_REF_DISTANCE,
|
|
DEFAULT_SOUND_EMITTER_MAX_DISTANCE,
|
|
DEFAULT_TELEPORT_TARGET_YAW_DEGREES,
|
|
PLAYER_START_COLLIDER_MODES,
|
|
PLAYER_START_GAMEPAD_ACTION_BINDINGS,
|
|
PLAYER_START_GAMEPAD_CAMERA_LOOK_BINDINGS,
|
|
PLAYER_START_GAMEPAD_BINDINGS,
|
|
PLAYER_START_LOCOMOTION_ACTIONS,
|
|
PLAYER_START_MOVEMENT_ACTIONS,
|
|
PLAYER_START_SYSTEM_ACTIONS,
|
|
PLAYER_START_NAVIGATION_MODES,
|
|
DEFAULT_SPOT_LIGHT_ANGLE_DEGREES,
|
|
DEFAULT_SPOT_LIGHT_COLOR_HEX,
|
|
DEFAULT_SPOT_LIGHT_DISTANCE,
|
|
DEFAULT_SPOT_LIGHT_DIRECTION,
|
|
DEFAULT_SPOT_LIGHT_INTENSITY,
|
|
DEFAULT_TRIGGER_VOLUME_SIZE,
|
|
areEntityInstancesEqual,
|
|
createCameraRigActorTargetRef,
|
|
createCameraRigEntity,
|
|
createCameraRigEntityTargetRef,
|
|
createCameraRigPlayerTargetRef,
|
|
createCameraRigWorldPointTargetRef,
|
|
clonePlayerStartInputBindings,
|
|
clonePlayerStartMovementTemplate,
|
|
createInteractableEntity,
|
|
createNpcAlwaysPresence,
|
|
createNpcColliderSettings,
|
|
createNpcEntity,
|
|
createPointLightEntity,
|
|
inferPlayerStartMovementTemplateKind,
|
|
createPlayerStartInputBindings,
|
|
createPlayerStartMovementTemplate,
|
|
createPlayerStartEntity,
|
|
createSceneEntryEntity,
|
|
createSoundEmitterEntity,
|
|
createSpotLightEntity,
|
|
createTeleportTargetEntity,
|
|
createTriggerVolumeEntity,
|
|
getEntityInstances,
|
|
getEntityKindLabel,
|
|
getPrimaryEnabledPlayerStartEntity,
|
|
getPlayerStartMouseBindingCodeForButton,
|
|
isPlayerStartMouseBindingCode,
|
|
normalizeEntityName,
|
|
normalizeYawDegrees,
|
|
normalizeInteractablePrompt,
|
|
resolveCameraRigDocumentPosition,
|
|
type CameraRigRailPlacementMode,
|
|
type CameraRigType,
|
|
type CameraRigTargetKind,
|
|
type CameraRigTransitionMode,
|
|
type PlayerStartGamepadActionBinding,
|
|
type PlayerStartGamepadCameraLookBinding,
|
|
type PlayerStartColliderMode,
|
|
type PlayerStartGamepadBinding,
|
|
type PlayerStartInputAction,
|
|
type PlayerStartInputBindings,
|
|
type PlayerStartKeyboardBindingCode,
|
|
type PlayerStartLocomotionAction,
|
|
type PlayerStartMovementAction,
|
|
type PlayerStartSystemAction,
|
|
type PlayerStartMovementTemplate,
|
|
type PlayerStartNavigationMode,
|
|
type EntityInstance,
|
|
type EntityKind
|
|
} from "../entities/entity-instances";
|
|
import {
|
|
createProjectDialogue,
|
|
createProjectDialogueLine
|
|
} from "../dialogues/project-dialogues";
|
|
import type {
|
|
ProjectDialogue,
|
|
ProjectDialogueLine
|
|
} from "../dialogues/project-dialogues";
|
|
import {
|
|
areProjectSequenceLibrariesEqual,
|
|
cloneProjectSequenceLibrary,
|
|
createProjectSequence,
|
|
getProjectSequences,
|
|
type ProjectSequence,
|
|
type ProjectSequenceLibrary
|
|
} from "../sequencer/project-sequences";
|
|
import {
|
|
getEntityDisplayLabelById,
|
|
getSortedEntityDisplayLabels
|
|
} from "../entities/entity-labels";
|
|
import { listNpcActorUsages } from "../entities/npc-actor-registry";
|
|
import {
|
|
areInteractionLinksEqual,
|
|
createControlInteractionLink,
|
|
createPlayAnimationInteractionLink,
|
|
createRunSequenceInteractionLink,
|
|
createPlaySoundInteractionLink,
|
|
createStopAnimationInteractionLink,
|
|
createStopSoundInteractionLink,
|
|
createTeleportPlayerInteractionLink,
|
|
createToggleVisibilityInteractionLink,
|
|
getInteractionLinksForSource,
|
|
type InteractionLink,
|
|
type InteractionTriggerKind
|
|
} from "../interactions/interaction-links";
|
|
import {
|
|
cloneControlEffect,
|
|
formatControlEffectValue,
|
|
formatControlTargetRef,
|
|
getControlTargetRefKey,
|
|
type ControlEffect
|
|
} from "../controls/control-surface";
|
|
import {
|
|
STARTER_MATERIAL_LIBRARY,
|
|
getStarterMaterialPreviewUrl,
|
|
getStarterMaterialTileSizeMeters,
|
|
type MaterialDef
|
|
} from "../materials/starter-material-library";
|
|
import { RunnerCanvas } from "../runner-web/RunnerCanvas";
|
|
import type {
|
|
FirstPersonTelemetry,
|
|
RuntimeLocomotionState
|
|
} from "../runtime-three/navigation-controller";
|
|
import type { RuntimeInteractionPrompt } from "../runtime-three/runtime-interaction-system";
|
|
import {
|
|
createDefaultRuntimeGlobalState,
|
|
type RuntimeGlobalState
|
|
} from "../runtime-three/runtime-global-state";
|
|
import type { RuntimeSceneTransitionRequest } from "../runtime-three/runtime-host";
|
|
import {
|
|
areRuntimeClockStatesEqual,
|
|
createRuntimeClockState,
|
|
resolveRuntimeTimeState,
|
|
type RuntimeClockState
|
|
} from "../runtime-three/runtime-project-time";
|
|
import {
|
|
buildRuntimeSceneFromDocument,
|
|
type RuntimeNavigationMode,
|
|
type RuntimeSceneDefinition
|
|
} from "../runtime-three/runtime-scene-build";
|
|
import {
|
|
EditorSimulationController,
|
|
type EditorSimulationUiSnapshot
|
|
} from "../runtime-three/editor-simulation-controller";
|
|
import { validateRuntimeSceneBuild } from "../runtime-three/runtime-scene-validation";
|
|
import { EditorAutosaveController } from "../serialization/editor-autosave";
|
|
import { Panel } from "../shared-ui/Panel";
|
|
import { NpcDialoguesPanel } from "./NpcDialoguesPanel";
|
|
import { ProjectSequencerPane } from "./ProjectSequencerPane";
|
|
import {
|
|
cloneSequenceEffect,
|
|
getProjectSequenceHeldSteps,
|
|
getProjectSequenceImpulseSteps
|
|
} from "../sequencer/project-sequence-steps";
|
|
import {
|
|
areProjectSchedulersEqual,
|
|
cloneProjectScheduleRoutine,
|
|
cloneProjectScheduler,
|
|
createProjectScheduleEveryDaySelection,
|
|
createProjectScheduleRoutine,
|
|
removeProjectScheduleRoutine,
|
|
upsertProjectScheduleRoutine,
|
|
type ProjectScheduleRoutine
|
|
} from "../scheduler/project-scheduler";
|
|
import {
|
|
createProjectScheduleEffectFromOption,
|
|
getProjectScheduleEffectOptionId,
|
|
getProjectSequenceControlStepClassForEffectOptionId,
|
|
listProjectInteractionControlEffectOptions,
|
|
listProjectInteractionControlTargetOptions,
|
|
getProjectScheduleTargetOptionByKey,
|
|
listProjectScheduleEffectOptions,
|
|
listProjectScheduleTargetOptions,
|
|
type ProjectScheduleEffectOptionId,
|
|
type ProjectScheduleTargetOption
|
|
} from "../scheduler/project-schedule-control-options";
|
|
import {
|
|
loadProjectPackage,
|
|
PROJECT_PACKAGE_FILE_EXTENSION,
|
|
saveProjectPackage
|
|
} from "../serialization/project-package";
|
|
import {
|
|
HierarchicalMenu,
|
|
type HierarchicalMenuItem,
|
|
type HierarchicalMenuPosition
|
|
} from "../shared-ui/HierarchicalMenu";
|
|
import { createWorldBackgroundStyle } from "../shared-ui/world-background-style";
|
|
import { ViewportPanel } from "../viewport-three/ViewportPanel";
|
|
import type { CreationViewportToolPreview } from "../viewport-three/viewport-transient-state";
|
|
import {
|
|
getViewportViewModeLabel,
|
|
type ViewportViewMode
|
|
} from "../viewport-three/viewport-view-modes";
|
|
import {
|
|
VIEWPORT_PANEL_IDS,
|
|
getViewportDisplayModeLabel,
|
|
getViewportLayoutModeLabel,
|
|
getViewportPanelLabel,
|
|
type ViewportDisplayMode,
|
|
type ViewportLayoutMode,
|
|
type ViewportPanelId,
|
|
type ViewportQuadSplit
|
|
} from "../viewport-three/viewport-layout";
|
|
import type { EditorStore } from "./editor-store";
|
|
import { useEditorStoreState } from "./use-editor-store";
|
|
|
|
interface AppProps {
|
|
store: EditorStore;
|
|
initialStatusMessage?: string;
|
|
}
|
|
|
|
interface Vec2Draft {
|
|
x: string;
|
|
y: string;
|
|
}
|
|
|
|
interface Vec3Draft {
|
|
x: string;
|
|
y: string;
|
|
z: string;
|
|
}
|
|
|
|
const DEFAULT_SCHEDULE_PANE_HEIGHT = 320;
|
|
const MIN_SCHEDULE_PANE_HEIGHT = 180;
|
|
const MIN_VIEWPORT_REGION_HEIGHT = 180;
|
|
|
|
interface PlayerStartMovementTemplateNumberDraft {
|
|
moveSpeed: string;
|
|
maxSpeed: string;
|
|
maxStepHeight: string;
|
|
jumpSpeed: string;
|
|
jumpBufferMs: string;
|
|
coyoteTimeMs: string;
|
|
variableJumpMaxHoldMs: string;
|
|
bunnyHopBoost: string;
|
|
sprintSpeedMultiplier: string;
|
|
crouchSpeedMultiplier: string;
|
|
}
|
|
|
|
type WorldTimePhaseKey = "dawn" | "dusk";
|
|
type WorldTimePhaseColorField =
|
|
| "skyTopColorHex"
|
|
| "skyBottomColorHex"
|
|
| "ambientColorHex"
|
|
| "lightColorHex";
|
|
type WorldTimePhaseNumericField =
|
|
| "ambientIntensityFactor"
|
|
| "lightIntensityFactor";
|
|
type WorldNightEnvironmentColorField = "ambientColorHex" | "lightColorHex";
|
|
type WorldNightEnvironmentNumericField =
|
|
| "ambientIntensityFactor"
|
|
| "lightIntensityFactor";
|
|
|
|
type InteractionSourceEntity = Extract<
|
|
EntityInstance,
|
|
{ kind: "triggerVolume" | "interactable" | "npc" }
|
|
>;
|
|
|
|
function getInteractionSourceEntityLabel(
|
|
entity: InteractionSourceEntity
|
|
): string {
|
|
switch (entity.kind) {
|
|
case "triggerVolume":
|
|
return "Trigger Volume";
|
|
case "interactable":
|
|
return "Interactable";
|
|
case "npc":
|
|
return "NPC";
|
|
}
|
|
}
|
|
|
|
function getModelInstanceCollisionModeDescription(
|
|
mode: ModelInstanceCollisionMode
|
|
): string {
|
|
switch (mode) {
|
|
case "none":
|
|
return "No generated collider is built for this model instance.";
|
|
case "terrain":
|
|
return "Builds a Rapier heightfield from a regular-grid terrain mesh. Unsupported terrain sources fail with build diagnostics.";
|
|
case "static":
|
|
return "Builds a fixed Rapier triangle-mesh collider from the imported model geometry.";
|
|
case "static-simple":
|
|
return "Builds a fixed compound collider by voxel-boxifying the imported mesh surface into merged structural slabs.";
|
|
case "dynamic":
|
|
return "Builds convex compound pieces for Rapier queries. In this slice they participate as fixed world collision, not fully simulated rigid bodies.";
|
|
case "simple":
|
|
return "Builds one cheap oriented box from the imported model bounds.";
|
|
}
|
|
}
|
|
|
|
function getCharacterColliderModeDescription(
|
|
mode: PlayerStartColliderMode,
|
|
subject: "Player Start" | "NPC"
|
|
): string {
|
|
switch (mode) {
|
|
case "capsule":
|
|
return `Uses a capsule ${subject.toLowerCase()} collider for standard grounded traversal.`;
|
|
case "box":
|
|
return `Uses an axis-aligned box ${subject.toLowerCase()} collider for sharper footprint bounds.`;
|
|
case "none":
|
|
return `Disables ${subject.toLowerCase()} collision. ${subject} still renders and keeps its authored facing.`;
|
|
}
|
|
}
|
|
|
|
function getPlayerStartColliderModeDescription(
|
|
mode: PlayerStartColliderMode
|
|
): string {
|
|
return getCharacterColliderModeDescription(mode, "Player Start");
|
|
}
|
|
|
|
function getNpcColliderModeDescription(mode: PlayerStartColliderMode): string {
|
|
return getCharacterColliderModeDescription(mode, "NPC");
|
|
}
|
|
|
|
function getPlayerStartMovementTemplateLabel(
|
|
kind: PlayerStartMovementTemplate["kind"]
|
|
): string {
|
|
switch (kind) {
|
|
case "default":
|
|
return "Default";
|
|
case "responsive":
|
|
return "Responsive";
|
|
case "custom":
|
|
return "Custom";
|
|
}
|
|
}
|
|
|
|
function getPlayerStartMovementTemplateDescription(
|
|
kind: PlayerStartMovementTemplate["kind"]
|
|
): string {
|
|
switch (kind) {
|
|
case "default":
|
|
return "Shared movement basis for First Person and Third Person with real jump, sprint, and crouch support on the runtime controller path.";
|
|
case "responsive":
|
|
return "Adds authored jump assists like jump buffering, coyote time, and variable jump height while keeping the same core movement basis.";
|
|
case "custom":
|
|
return "Uses the exact authored movement settings from the controls below.";
|
|
}
|
|
}
|
|
|
|
function getPlayerStartInputActionLabel(
|
|
action: PlayerStartInputAction
|
|
): string {
|
|
switch (action) {
|
|
case "moveForward":
|
|
return "Forward";
|
|
case "moveBackward":
|
|
return "Backward";
|
|
case "moveLeft":
|
|
return "Left";
|
|
case "moveRight":
|
|
return "Right";
|
|
case "jump":
|
|
return "Jump";
|
|
case "sprint":
|
|
return "Sprint";
|
|
case "crouch":
|
|
return "Crouch";
|
|
case "interact":
|
|
return "Interact";
|
|
case "clearTarget":
|
|
return "Clear Target";
|
|
case "pauseTime":
|
|
return "Pause";
|
|
}
|
|
}
|
|
|
|
function formatPlayerStartKeyboardBindingLabel(
|
|
code: PlayerStartKeyboardBindingCode
|
|
): string {
|
|
if (/^Key[A-Z]$/.test(code)) {
|
|
return code.slice(3);
|
|
}
|
|
|
|
if (/^Digit[0-9]$/.test(code)) {
|
|
return code.slice(5);
|
|
}
|
|
|
|
switch (code) {
|
|
case "MouseLeft":
|
|
return "Left Mouse";
|
|
case "MouseMiddle":
|
|
return "Middle Mouse";
|
|
case "MouseRight":
|
|
return "Right Mouse";
|
|
case "MouseBack":
|
|
return "Mouse Back";
|
|
case "MouseForward":
|
|
return "Mouse Forward";
|
|
case "ArrowUp":
|
|
return "Arrow Up";
|
|
case "ArrowLeft":
|
|
return "Arrow Left";
|
|
case "ArrowDown":
|
|
return "Arrow Down";
|
|
case "ArrowRight":
|
|
return "Arrow Right";
|
|
case "Space":
|
|
return "Space";
|
|
case "Tab":
|
|
return "Tab";
|
|
case "Enter":
|
|
return "Enter";
|
|
case "Backspace":
|
|
return "Backspace";
|
|
case "Escape":
|
|
return "Escape";
|
|
default:
|
|
return code.replace(/([a-z0-9])([A-Z])/g, "$1 $2");
|
|
}
|
|
}
|
|
|
|
function formatPlayerStartGamepadBindingLabel(
|
|
binding: PlayerStartGamepadBinding
|
|
): string {
|
|
switch (binding) {
|
|
case "leftStickUp":
|
|
return "Left Stick Up";
|
|
case "leftStickDown":
|
|
return "Left Stick Down";
|
|
case "leftStickLeft":
|
|
return "Left Stick Left";
|
|
case "leftStickRight":
|
|
return "Left Stick Right";
|
|
case "dpadUp":
|
|
return "D-Pad Up";
|
|
case "dpadDown":
|
|
return "D-Pad Down";
|
|
case "dpadLeft":
|
|
return "D-Pad Left";
|
|
case "dpadRight":
|
|
return "D-Pad Right";
|
|
}
|
|
}
|
|
|
|
function formatPlayerStartGamepadActionBindingLabel(
|
|
binding: PlayerStartGamepadActionBinding
|
|
): string {
|
|
switch (binding) {
|
|
case "buttonSouth":
|
|
return "South Button";
|
|
case "buttonEast":
|
|
return "East Button";
|
|
case "buttonWest":
|
|
return "West Button";
|
|
case "buttonNorth":
|
|
return "North Button";
|
|
case "buttonMenu":
|
|
return "Menu / Start";
|
|
case "leftShoulder":
|
|
return "Left Shoulder";
|
|
case "rightShoulder":
|
|
return "Right Shoulder";
|
|
case "leftTrigger":
|
|
return "Left Trigger";
|
|
case "rightTrigger":
|
|
return "Right Trigger";
|
|
case "leftStickPress":
|
|
return "Left Stick Press";
|
|
case "rightStickPress":
|
|
return "Right Stick Press";
|
|
}
|
|
}
|
|
|
|
function formatPlayerStartGamepadCameraLookBindingLabel(
|
|
binding: PlayerStartGamepadCameraLookBinding
|
|
): string {
|
|
switch (binding) {
|
|
case "rightStick":
|
|
return "Right Stick";
|
|
}
|
|
}
|
|
|
|
const STARTER_MATERIAL_ORDER = new Map(
|
|
STARTER_MATERIAL_LIBRARY.map((material, index) => [material.id, index])
|
|
);
|
|
const TERRAIN_SCULPT_BRUSH_TOOLS: Exclude<TerrainBrushTool, "paint">[] = [
|
|
"raise",
|
|
"lower",
|
|
"smooth",
|
|
"flatten"
|
|
];
|
|
const MIN_VIEWPORT_QUAD_SPLIT = 0.2;
|
|
const MAX_VIEWPORT_QUAD_SPLIT = 0.8;
|
|
|
|
type ViewportQuadResizeMode = "vertical" | "horizontal" | "center";
|
|
type NumberInputStep = number | "any";
|
|
|
|
function formatVec3(vector: Vec3): string {
|
|
return `${vector.x}, ${vector.y}, ${vector.z}`;
|
|
}
|
|
|
|
function resolveOptionalPositiveNumber(
|
|
value: string,
|
|
fallback: number
|
|
): number {
|
|
const parsedValue = Number(value);
|
|
return Number.isFinite(parsedValue) && parsedValue > 0
|
|
? parsedValue
|
|
: fallback;
|
|
}
|
|
|
|
function getWhiteboxInputStep(enabled: boolean, step: number): NumberInputStep {
|
|
return enabled ? step : "any";
|
|
}
|
|
|
|
function formatDiagnosticCount(count: number, label: string): string {
|
|
return `${count} ${label}${count === 1 ? "" : "s"}`;
|
|
}
|
|
|
|
function clampViewportQuadSplitValue(value: number): number {
|
|
return Math.min(
|
|
MAX_VIEWPORT_QUAD_SPLIT,
|
|
Math.max(MIN_VIEWPORT_QUAD_SPLIT, value)
|
|
);
|
|
}
|
|
|
|
function createViewportQuadPanelsStyle(
|
|
viewportQuadSplit: ViewportQuadSplit
|
|
): CSSProperties {
|
|
return {
|
|
"--viewport-quad-split-x": String(viewportQuadSplit.x),
|
|
"--viewport-quad-split-y": String(viewportQuadSplit.y)
|
|
} as CSSProperties;
|
|
}
|
|
|
|
function getViewportQuadResizeCursor(
|
|
resizeMode: ViewportQuadResizeMode
|
|
): string {
|
|
switch (resizeMode) {
|
|
case "vertical":
|
|
return "col-resize";
|
|
case "horizontal":
|
|
return "row-resize";
|
|
case "center":
|
|
return "move";
|
|
}
|
|
}
|
|
|
|
function createVec2Draft(vector: Vec2): Vec2Draft {
|
|
return {
|
|
x: String(vector.x),
|
|
y: String(vector.y)
|
|
};
|
|
}
|
|
|
|
function createMaybeMixedVec2Draft(
|
|
values: readonly Vec2[],
|
|
equals: (left: number, right: number) => boolean = (left, right) =>
|
|
left === right
|
|
): Vec2Draft {
|
|
if (values.length === 0) {
|
|
return createVec2Draft(createDefaultFaceUvState().offset);
|
|
}
|
|
|
|
const [firstValue, ...remainingValues] = values;
|
|
|
|
return {
|
|
x: remainingValues.every((value) => equals(value.x, firstValue.x))
|
|
? String(firstValue.x)
|
|
: "",
|
|
y: remainingValues.every((value) => equals(value.y, firstValue.y))
|
|
? String(firstValue.y)
|
|
: ""
|
|
};
|
|
}
|
|
|
|
function createVec3Draft(vector: Vec3): Vec3Draft {
|
|
return {
|
|
x: String(vector.x),
|
|
y: String(vector.y),
|
|
z: String(vector.z)
|
|
};
|
|
}
|
|
|
|
function formatRuntimeDayPhaseLabel(
|
|
dayPhase: ReturnType<typeof resolveRuntimeTimeState>["dayPhase"]
|
|
): string {
|
|
return dayPhase.charAt(0).toUpperCase() + dayPhase.slice(1);
|
|
}
|
|
|
|
function createInitialEditorSimulationUiSnapshot(
|
|
time: ProjectTimeSettings
|
|
): EditorSimulationUiSnapshot {
|
|
return {
|
|
playing: false,
|
|
overrideActive: false,
|
|
clock: createRuntimeClockState(time),
|
|
message: null,
|
|
sceneReady: false,
|
|
sceneVersion: 0,
|
|
frameVersion: 0
|
|
};
|
|
}
|
|
|
|
function createPlayerStartMovementTemplateNumberDraft(
|
|
template: PlayerStartMovementTemplate
|
|
): PlayerStartMovementTemplateNumberDraft {
|
|
return {
|
|
moveSpeed: String(template.moveSpeed),
|
|
maxSpeed: String(template.maxSpeed),
|
|
maxStepHeight: String(template.maxStepHeight),
|
|
jumpSpeed: String(template.jump.speed),
|
|
jumpBufferMs: String(template.jump.bufferMs),
|
|
coyoteTimeMs: String(template.jump.coyoteTimeMs),
|
|
variableJumpMaxHoldMs: String(template.jump.maxHoldMs),
|
|
bunnyHopBoost: String(template.jump.bunnyHopBoost),
|
|
sprintSpeedMultiplier: String(template.sprint.speedMultiplier),
|
|
crouchSpeedMultiplier: String(template.crouch.speedMultiplier)
|
|
};
|
|
}
|
|
|
|
function readVec2Draft(draft: Vec2Draft, label: string): Vec2 {
|
|
if (draft.x.trim().length === 0 || draft.y.trim().length === 0) {
|
|
throw new Error(`${label} values must be provided.`);
|
|
}
|
|
|
|
const vector = {
|
|
x: Number(draft.x),
|
|
y: Number(draft.y)
|
|
};
|
|
|
|
if (!Number.isFinite(vector.x) || !Number.isFinite(vector.y)) {
|
|
throw new Error(`${label} values must be finite numbers.`);
|
|
}
|
|
|
|
return vector;
|
|
}
|
|
|
|
function readPositiveVec2Draft(draft: Vec2Draft, label: string): Vec2 {
|
|
const vector = readVec2Draft(draft, label);
|
|
|
|
if (vector.x <= 0 || vector.y <= 0) {
|
|
throw new Error(`${label} values must remain positive.`);
|
|
}
|
|
|
|
return vector;
|
|
}
|
|
|
|
function readPositiveVec3Draft(draft: Vec3Draft, label: string): Vec3 {
|
|
const vector = readVec3Draft(draft, label);
|
|
|
|
if (vector.x <= 0 || vector.y <= 0 || vector.z <= 0) {
|
|
throw new Error(`${label} values must remain positive.`);
|
|
}
|
|
|
|
return vector;
|
|
}
|
|
|
|
function readVec3Draft(draft: Vec3Draft, label: string): Vec3 {
|
|
const vector = {
|
|
x: Number(draft.x),
|
|
y: Number(draft.y),
|
|
z: Number(draft.z)
|
|
};
|
|
|
|
if (
|
|
!Number.isFinite(vector.x) ||
|
|
!Number.isFinite(vector.y) ||
|
|
!Number.isFinite(vector.z)
|
|
) {
|
|
throw new Error(`${label} values must be finite numbers.`);
|
|
}
|
|
|
|
return vector;
|
|
}
|
|
|
|
function readYawDegreesDraft(source: string): number {
|
|
const yawDegrees = Number(source);
|
|
|
|
if (!Number.isFinite(yawDegrees)) {
|
|
throw new Error("Player start yaw must be a finite number.");
|
|
}
|
|
|
|
return normalizeYawDegrees(yawDegrees);
|
|
}
|
|
|
|
function readInteractablePromptDraft(source: string): string {
|
|
return normalizeInteractablePrompt(source);
|
|
}
|
|
|
|
function readNonNegativeNumberDraft(source: string, label: string): number {
|
|
const value = Number(source);
|
|
|
|
if (!Number.isFinite(value) || value < 0) {
|
|
throw new Error(
|
|
`${label} must be a finite number greater than or equal to zero.`
|
|
);
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
function readFiniteNumberDraft(source: string, label: string): number {
|
|
const value = Number(source);
|
|
|
|
if (!Number.isFinite(value)) {
|
|
throw new Error(`${label} must be a finite number.`);
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
function readPositiveIntegerDraft(source: string, label: string): number {
|
|
const value = Number(source);
|
|
|
|
if (!Number.isFinite(value) || value <= 0 || !Number.isInteger(value)) {
|
|
throw new Error(`${label} must be a positive integer.`);
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
function readWaterFoamContactLimitDraft(source: string): number {
|
|
const value = readPositiveIntegerDraft(source, "Water foam contact limit");
|
|
|
|
if (value > MAX_BOX_BRUSH_WATER_FOAM_CONTACT_LIMIT) {
|
|
throw new Error(
|
|
`Water foam contact limit must be ${MAX_BOX_BRUSH_WATER_FOAM_CONTACT_LIMIT} or less.`
|
|
);
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
function readPositiveNumberDraft(source: string, label: string): number {
|
|
const value = Number(source);
|
|
|
|
if (!Number.isFinite(value) || value <= 0) {
|
|
throw new Error(`${label} must be a finite number greater than zero.`);
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
function assertProjectTimeSettingsAreOrdered(time: ProjectTimeSettings) {
|
|
if (time.sunriseTimeOfDayHours >= time.sunsetTimeOfDayHours) {
|
|
throw new Error("Project sunrise must be earlier than project sunset.");
|
|
}
|
|
}
|
|
|
|
function areVec2Equal(left: Vec2, right: Vec2): boolean {
|
|
return left.x === right.x && left.y === right.y;
|
|
}
|
|
|
|
function areVec3Equal(left: Vec3, right: Vec3): boolean {
|
|
return left.x === right.x && left.y === right.y && left.z === right.z;
|
|
}
|
|
|
|
function maybeSnapVec3(vector: Vec3, enabled: boolean, step: number): Vec3 {
|
|
if (!enabled) {
|
|
return vector;
|
|
}
|
|
|
|
return {
|
|
x: Math.round(vector.x / step) * step,
|
|
y: Math.round(vector.y / step) * step,
|
|
z: Math.round(vector.z / step) * step
|
|
};
|
|
}
|
|
|
|
function maybeSnapPositiveSize(
|
|
size: Vec3,
|
|
enabled: boolean,
|
|
step: number
|
|
): Vec3 {
|
|
const clampComponent = (value: number) => Math.max(0.01, Math.abs(value));
|
|
|
|
if (!enabled) {
|
|
return {
|
|
x: clampComponent(size.x),
|
|
y: clampComponent(size.y),
|
|
z: clampComponent(size.z)
|
|
};
|
|
}
|
|
|
|
return {
|
|
x: Math.max(0.01, Math.round(Math.abs(size.x) / step) * step),
|
|
y: Math.max(0.01, Math.round(Math.abs(size.y) / step) * step),
|
|
z: Math.max(0.01, Math.round(Math.abs(size.z) / step) * step)
|
|
};
|
|
}
|
|
|
|
function areFaceUvStatesEqual(left: FaceUvState, right: FaceUvState): boolean {
|
|
return (
|
|
areVec2Equal(left.offset, right.offset) &&
|
|
areVec2Equal(left.scale, right.scale) &&
|
|
left.rotationQuarterTurns === right.rotationQuarterTurns &&
|
|
left.flipU === right.flipU &&
|
|
left.flipV === right.flipV
|
|
);
|
|
}
|
|
|
|
function getSharedBrushFaceMaterialId(brush: Brush): string | null | undefined {
|
|
const faceIds = getBrushFaceIds(brush);
|
|
const firstFace = brush.faces[faceIds[0]];
|
|
|
|
if (firstFace === undefined) {
|
|
return null;
|
|
}
|
|
|
|
const firstMaterialId = firstFace.materialId;
|
|
|
|
return faceIds
|
|
.slice(1)
|
|
.every((faceId) => brush.faces[faceId]?.materialId === firstMaterialId)
|
|
? firstMaterialId
|
|
: undefined;
|
|
}
|
|
|
|
function getSelectedBoxBrush(
|
|
selection: EditorSelection,
|
|
brushes: Brush[]
|
|
): Brush | null {
|
|
const selectedBrushId = getSingleSelectedBrushId(selection);
|
|
|
|
if (selectedBrushId === null) {
|
|
return null;
|
|
}
|
|
|
|
return brushes.find((brush) => brush.id === selectedBrushId) ?? null;
|
|
}
|
|
|
|
function getSelectedEntity(
|
|
selection: EditorSelection,
|
|
entities: EntityInstance[]
|
|
): EntityInstance | null {
|
|
const selectedEntityId = getSingleSelectedEntityId(selection);
|
|
|
|
if (selectedEntityId === null) {
|
|
return null;
|
|
}
|
|
|
|
return entities.find((entity) => entity.id === selectedEntityId) ?? null;
|
|
}
|
|
|
|
function getSelectedModelInstance(
|
|
selection: EditorSelection,
|
|
modelInstances: ModelInstance[]
|
|
): ModelInstance | null {
|
|
const selectedModelInstanceId = getSingleSelectedModelInstanceId(selection);
|
|
|
|
if (selectedModelInstanceId === null) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
modelInstances.find(
|
|
(modelInstance) => modelInstance.id === selectedModelInstanceId
|
|
) ?? null
|
|
);
|
|
}
|
|
|
|
function getSelectedPath(
|
|
selection: EditorSelection,
|
|
paths: ScenePath[]
|
|
): ScenePath | null {
|
|
const selectedPathId = getSingleSelectedPathOwnerId(selection);
|
|
|
|
if (selectedPathId === null) {
|
|
return null;
|
|
}
|
|
|
|
return paths.find((path) => path.id === selectedPathId) ?? null;
|
|
}
|
|
|
|
function getSelectedTerrain(
|
|
selection: EditorSelection,
|
|
terrains: Terrain[]
|
|
): Terrain | null {
|
|
const selectedTerrainId = getSingleSelectedTerrainId(selection);
|
|
|
|
if (selectedTerrainId === null) {
|
|
return null;
|
|
}
|
|
|
|
return terrains.find((terrain) => terrain.id === selectedTerrainId) ?? null;
|
|
}
|
|
|
|
function getSelectedPathPointState(
|
|
selection: EditorSelection,
|
|
path: ScenePath | null
|
|
): { point: ScenePathPoint; index: number } | null {
|
|
const selectedPathPoint = getSingleSelectedPathPoint(selection);
|
|
|
|
if (
|
|
selectedPathPoint === null ||
|
|
path === null ||
|
|
selectedPathPoint.pathId !== path.id
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const pointIndex = getScenePathPointIndex(path, selectedPathPoint.pointId);
|
|
|
|
if (pointIndex === -1) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
point: path.points[pointIndex],
|
|
index: pointIndex
|
|
};
|
|
}
|
|
|
|
function isModelAsset(asset: ProjectAssetRecord): asset is ModelAssetRecord {
|
|
return asset.kind === "model";
|
|
}
|
|
|
|
function isImageAsset(asset: ProjectAssetRecord): asset is ImageAssetRecord {
|
|
return asset.kind === "image";
|
|
}
|
|
|
|
function isAudioAsset(asset: ProjectAssetRecord): asset is AudioAssetRecord {
|
|
return asset.kind === "audio";
|
|
}
|
|
|
|
function formatByteLength(byteLength: number): string {
|
|
if (byteLength < 1024) {
|
|
return `${byteLength} B`;
|
|
}
|
|
|
|
const kilobytes = byteLength / 1024;
|
|
|
|
if (kilobytes < 1024) {
|
|
return `${kilobytes.toFixed(kilobytes >= 10 ? 0 : 1)} KB`;
|
|
}
|
|
|
|
return `${(kilobytes / 1024).toFixed(1)} MB`;
|
|
}
|
|
|
|
function formatModelBoundingBoxLabel(asset: ModelAssetRecord): string {
|
|
if (asset.metadata.boundingBox === null) {
|
|
return "Bounds unavailable";
|
|
}
|
|
|
|
const { size } = asset.metadata.boundingBox;
|
|
|
|
return `Bounds ${size.x.toFixed(2)} x ${size.y.toFixed(2)} x ${size.z.toFixed(2)} m`;
|
|
}
|
|
|
|
function formatModelAssetSummary(asset: ModelAssetRecord): string {
|
|
const details = [
|
|
asset.metadata.format.toUpperCase(),
|
|
formatByteLength(asset.byteLength),
|
|
`${asset.metadata.meshCount} mesh${asset.metadata.meshCount === 1 ? "" : "es"}`,
|
|
`${asset.metadata.materialNames.length} material${asset.metadata.materialNames.length === 1 ? "" : "s"}`,
|
|
`${asset.metadata.textureNames.length} texture${asset.metadata.textureNames.length === 1 ? "" : "s"}`
|
|
];
|
|
|
|
if (asset.metadata.animationNames.length > 0) {
|
|
details.push(
|
|
`${asset.metadata.animationNames.length} animation${asset.metadata.animationNames.length === 1 ? "" : "s"}`
|
|
);
|
|
}
|
|
|
|
return details.join(" | ");
|
|
}
|
|
|
|
function formatImageAssetSummary(asset: ImageAssetRecord): string {
|
|
const details = [
|
|
`${asset.metadata.width} x ${asset.metadata.height}`,
|
|
asset.metadata.hasAlpha ? "alpha" : "opaque",
|
|
formatByteLength(asset.byteLength)
|
|
];
|
|
|
|
return details.join(" | ");
|
|
}
|
|
|
|
function formatAudioAssetSummary(asset: AudioAssetRecord): string {
|
|
const details = [
|
|
asset.metadata.durationSeconds === null
|
|
? "duration unavailable"
|
|
: `${asset.metadata.durationSeconds.toFixed(2)}s`,
|
|
asset.metadata.channelCount === null
|
|
? "channels unavailable"
|
|
: `${asset.metadata.channelCount} channel${asset.metadata.channelCount === 1 ? "" : "s"}`,
|
|
asset.metadata.sampleRateHz === null
|
|
? "sample rate unavailable"
|
|
: `${asset.metadata.sampleRateHz} Hz`,
|
|
formatByteLength(asset.byteLength)
|
|
];
|
|
|
|
return details.join(" | ");
|
|
}
|
|
|
|
function formatProjectAssetSummary(asset: ProjectAssetRecord): string {
|
|
return asset.kind === "model"
|
|
? formatModelAssetSummary(asset)
|
|
: asset.kind === "image"
|
|
? formatImageAssetSummary(asset)
|
|
: formatAudioAssetSummary(asset);
|
|
}
|
|
|
|
function formatAssetHoverStatus(asset: ProjectAssetRecord): string {
|
|
const details = [
|
|
`${getProjectAssetKindLabel(asset.kind)} asset`,
|
|
asset.mimeType,
|
|
asset.kind === "model"
|
|
? formatModelAssetSummary(asset)
|
|
: asset.kind === "image"
|
|
? formatImageAssetSummary(asset)
|
|
: formatAudioAssetSummary(asset),
|
|
`Storage key: ${asset.storageKey}`
|
|
];
|
|
|
|
if (asset.kind === "model") {
|
|
details.push(formatModelBoundingBoxLabel(asset));
|
|
}
|
|
|
|
if (asset.metadata.warnings.length > 0) {
|
|
details.push(`Warnings: ${asset.metadata.warnings.join(" | ")}`);
|
|
}
|
|
|
|
return `${asset.sourceName} | ${details.join(" | ")}`;
|
|
}
|
|
|
|
function getBrushLabel(brush: Brush, index: number): string {
|
|
return brush.name ?? getBrushDefaultName(brush, index);
|
|
}
|
|
|
|
function getPathLabelById(pathId: string, paths: ScenePath[]): string {
|
|
const pathIndex = paths.findIndex((path) => path.id === pathId);
|
|
return pathIndex === -1
|
|
? "Path"
|
|
: getScenePathLabel(paths[pathIndex], pathIndex);
|
|
}
|
|
|
|
function getTerrainLabel(terrain: Terrain, index: number): string {
|
|
return terrain.name ?? `Terrain ${index + 1}`;
|
|
}
|
|
|
|
function getTerrainLabelById(terrainId: string, terrains: Terrain[]): string {
|
|
const terrainIndex = terrains.findIndex(
|
|
(terrain) => terrain.id === terrainId
|
|
);
|
|
return terrainIndex === -1
|
|
? getTerrainKindLabel()
|
|
: getTerrainLabel(terrains[terrainIndex], terrainIndex);
|
|
}
|
|
|
|
function formatAuthoredObjectStateSummary(state: {
|
|
visible: boolean;
|
|
enabled: boolean;
|
|
}): string | null {
|
|
const parts: string[] = [];
|
|
|
|
if (!state.enabled) {
|
|
parts.push("Disabled");
|
|
}
|
|
|
|
if (!state.visible) {
|
|
parts.push("Hidden");
|
|
}
|
|
|
|
return parts.length === 0 ? null : parts.join(" | ");
|
|
}
|
|
|
|
function getBrushLabelById(brushId: string, brushes: Brush[]): string {
|
|
const brushIndex = brushes.findIndex((brush) => brush.id === brushId);
|
|
return brushIndex === -1
|
|
? "Whitebox Solid"
|
|
: getBrushLabel(brushes[brushIndex], brushIndex);
|
|
}
|
|
|
|
function getSelectedBrushLabel(
|
|
selection: EditorSelection,
|
|
brushes: Brush[],
|
|
activeSelectionId: string | null
|
|
): string {
|
|
const selectedBrushId =
|
|
selection.kind === "brushes"
|
|
? resolveSelectionActiveId(selection, activeSelectionId)
|
|
: getSingleSelectedBrushId(selection);
|
|
|
|
if (selectedBrushId === null) {
|
|
return "No solid selected";
|
|
}
|
|
|
|
return getBrushLabelById(selectedBrushId, brushes);
|
|
}
|
|
|
|
function describeSelection(
|
|
selection: EditorSelection,
|
|
brushes: Brush[],
|
|
terrains: Terrain[],
|
|
paths: ScenePath[],
|
|
modelInstances: Record<string, ModelInstance>,
|
|
assets: Record<string, ProjectAssetRecord>,
|
|
entities: Record<string, EntityInstance>,
|
|
activeSelectionId: string | null
|
|
): string {
|
|
switch (selection.kind) {
|
|
case "none":
|
|
return "No authored selection";
|
|
case "brushes":
|
|
return `${selection.ids.length} solid${selection.ids.length === 1 ? "" : "s"} selected (${getSelectedBrushLabel(selection, brushes, activeSelectionId)})`;
|
|
case "brushFace": {
|
|
const brush = brushes.find(
|
|
(candidate) => candidate.id === selection.brushId
|
|
);
|
|
const faceLabel =
|
|
brush === undefined
|
|
? selection.faceId
|
|
: getBrushFaceLabel(brush, selection.faceId);
|
|
return `1 face selected (${faceLabel} on ${getBrushLabelById(selection.brushId, brushes)})`;
|
|
}
|
|
case "brushEdge": {
|
|
const brush = brushes.find(
|
|
(candidate) => candidate.id === selection.brushId
|
|
);
|
|
const edgeLabel =
|
|
brush === undefined
|
|
? selection.edgeId
|
|
: getBrushEdgeLabel(brush, selection.edgeId);
|
|
return `1 edge selected (${edgeLabel} on ${getBrushLabelById(selection.brushId, brushes)})`;
|
|
}
|
|
case "brushVertex": {
|
|
const brush = brushes.find(
|
|
(candidate) => candidate.id === selection.brushId
|
|
);
|
|
const vertexLabel =
|
|
brush === undefined
|
|
? selection.vertexId
|
|
: getBrushVertexLabel(brush, selection.vertexId);
|
|
return `1 vertex selected (${vertexLabel} on ${getBrushLabelById(selection.brushId, brushes)})`;
|
|
}
|
|
case "terrains":
|
|
return `${selection.ids.length} terrain${selection.ids.length === 1 ? "" : "s"} selected (${getTerrainLabelById(resolveSelectionActiveId(selection, activeSelectionId) ?? selection.ids[0], terrains)})`;
|
|
case "paths":
|
|
return `${selection.ids.length} path${selection.ids.length === 1 ? "" : "s"} selected (${getPathLabelById(selection.ids[0], paths)})`;
|
|
case "pathPoint": {
|
|
const path = paths.find(
|
|
(candidatePath) => candidatePath.id === selection.pathId
|
|
);
|
|
const pointIndex =
|
|
path === undefined
|
|
? -1
|
|
: getScenePathPointIndex(path, selection.pointId);
|
|
const pointLabel =
|
|
pointIndex === -1 ? "Path Point" : `Point ${pointIndex + 1}`;
|
|
return `${pointLabel} selected (${getPathLabelById(selection.pathId, paths)})`;
|
|
}
|
|
case "entities":
|
|
return `${selection.ids.length} entit${selection.ids.length === 1 ? "y" : "ies"} selected (${getEntityDisplayLabelById(resolveSelectionActiveId(selection, activeSelectionId) ?? selection.ids[0], entities, assets)})`;
|
|
case "modelInstances":
|
|
return `${selection.ids.length} model instance${selection.ids.length === 1 ? "" : "s"} selected (${getModelInstanceDisplayLabelById(resolveSelectionActiveId(selection, activeSelectionId) ?? selection.ids[0], modelInstances, assets)})`;
|
|
default:
|
|
return "Unknown selection";
|
|
}
|
|
}
|
|
|
|
function getMultiSelectionSummary(
|
|
selection: EditorSelection,
|
|
activeSelectionId: string | null,
|
|
brushes: Brush[],
|
|
modelInstances: Record<string, ModelInstance>,
|
|
assets: Record<string, ProjectAssetRecord>,
|
|
entities: Record<string, EntityInstance>
|
|
): {
|
|
kindLabel: string;
|
|
count: number;
|
|
activeId: string;
|
|
activeLabel: string;
|
|
selectedItems: Array<{ id: string; label: string }>;
|
|
} | null {
|
|
const resolvedActiveSelectionId = resolveSelectionActiveId(
|
|
selection,
|
|
activeSelectionId
|
|
);
|
|
|
|
if (resolvedActiveSelectionId === null) {
|
|
return null;
|
|
}
|
|
|
|
switch (selection.kind) {
|
|
case "brushes":
|
|
if (selection.ids.length <= 1) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
kindLabel: "Whitebox Solids",
|
|
count: selection.ids.length,
|
|
activeId: resolvedActiveSelectionId,
|
|
activeLabel: getBrushLabelById(resolvedActiveSelectionId, brushes),
|
|
selectedItems: selection.ids.map((id) => ({
|
|
id,
|
|
label: getBrushLabelById(id, brushes)
|
|
}))
|
|
};
|
|
case "entities":
|
|
if (selection.ids.length <= 1) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
kindLabel: "Entities",
|
|
count: selection.ids.length,
|
|
activeId: resolvedActiveSelectionId,
|
|
activeLabel: getEntityDisplayLabelById(
|
|
resolvedActiveSelectionId,
|
|
entities,
|
|
assets
|
|
),
|
|
selectedItems: selection.ids.map((id) => ({
|
|
id,
|
|
label: getEntityDisplayLabelById(id, entities, assets)
|
|
}))
|
|
};
|
|
case "modelInstances":
|
|
if (selection.ids.length <= 1) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
kindLabel: "Model Instances",
|
|
count: selection.ids.length,
|
|
activeId: resolvedActiveSelectionId,
|
|
activeLabel: getModelInstanceDisplayLabelById(
|
|
resolvedActiveSelectionId,
|
|
modelInstances,
|
|
assets
|
|
),
|
|
selectedItems: selection.ids.map((id) => ({
|
|
id,
|
|
label: getModelInstanceDisplayLabelById(id, modelInstances, assets)
|
|
}))
|
|
};
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function getWhiteboxSelectionModeStatus(mode: WhiteboxSelectionMode): string {
|
|
switch (mode) {
|
|
case "object":
|
|
return "Whitebox selection mode set to Object. Whole-solid transforms are available.";
|
|
case "face":
|
|
return "Whitebox selection mode set to Face. Click a face to edit materials and UVs.";
|
|
case "edge":
|
|
return "Whitebox selection mode set to Edge. Edge transforms land in the next slice.";
|
|
case "vertex":
|
|
return "Whitebox selection mode set to Vertex. Vertex transforms land in the next slice.";
|
|
}
|
|
}
|
|
|
|
function resolveWhiteboxSelectionModeShortcut(
|
|
event: globalThis.KeyboardEvent
|
|
): WhiteboxSelectionMode | null {
|
|
if (event.altKey || event.ctrlKey || event.metaKey) {
|
|
return null;
|
|
}
|
|
|
|
if (event.key === "^" || (event.shiftKey && event.code === "Digit6")) {
|
|
return "object";
|
|
}
|
|
|
|
switch (event.code) {
|
|
case "Digit1":
|
|
return "face";
|
|
case "Digit2":
|
|
return "edge";
|
|
case "Digit3":
|
|
return "vertex";
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function getInteractionTriggerLabel(trigger: InteractionTriggerKind): string {
|
|
switch (trigger) {
|
|
case "enter":
|
|
return "On Enter";
|
|
case "exit":
|
|
return "On Exit";
|
|
case "click":
|
|
return "On Click";
|
|
}
|
|
}
|
|
|
|
function getInteractionActionLabel(link: InteractionLink): string {
|
|
switch (link.action.type) {
|
|
case "teleportPlayer":
|
|
return "Teleport Player";
|
|
case "toggleVisibility":
|
|
return "Toggle Visibility";
|
|
case "playAnimation":
|
|
return "Play Animation";
|
|
case "stopAnimation":
|
|
return "Stop Animation";
|
|
case "playSound":
|
|
return "Play Sound";
|
|
case "stopSound":
|
|
return "Stop Sound";
|
|
case "runSequence":
|
|
return "Run Sequence";
|
|
case "control":
|
|
return "Control Effect";
|
|
}
|
|
}
|
|
|
|
function isLegacyInteractionActionType(
|
|
actionType: InteractionLink["action"]["type"]
|
|
): boolean {
|
|
return actionType !== "runSequence" && actionType !== "control";
|
|
}
|
|
|
|
function getVisibilityModeSelectValue(
|
|
visible: boolean | undefined
|
|
): "toggle" | "show" | "hide" {
|
|
if (visible === true) {
|
|
return "show";
|
|
}
|
|
|
|
if (visible === false) {
|
|
return "hide";
|
|
}
|
|
|
|
return "toggle";
|
|
}
|
|
|
|
function readVisibilityModeSelectValue(
|
|
value: "toggle" | "show" | "hide"
|
|
): boolean | undefined {
|
|
switch (value) {
|
|
case "toggle":
|
|
return undefined;
|
|
case "show":
|
|
return true;
|
|
case "hide":
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function commitOnEnter(
|
|
event: ReactKeyboardEvent<HTMLInputElement>,
|
|
commit: () => void
|
|
) {
|
|
if (event.key !== "Enter") {
|
|
return;
|
|
}
|
|
|
|
event.currentTarget.blur();
|
|
commit();
|
|
}
|
|
|
|
function getControlEffectNumericValue(effect: ControlEffect): number | null {
|
|
switch (effect.type) {
|
|
case "setSoundVolume":
|
|
return effect.volume;
|
|
case "setLightIntensity":
|
|
case "setAmbientLightIntensity":
|
|
case "setSunLightIntensity":
|
|
return effect.intensity;
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function getControlEffectColorValue(effect: ControlEffect): string | null {
|
|
switch (effect.type) {
|
|
case "setLightColor":
|
|
case "setAmbientLightColor":
|
|
case "setSunLightColor":
|
|
return effect.colorHex;
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function readSequenceVisibilityTargetKey(value: string):
|
|
| {
|
|
kind: "brush";
|
|
brushId: string;
|
|
}
|
|
| {
|
|
kind: "modelInstance";
|
|
modelInstanceId: string;
|
|
} {
|
|
if (value.startsWith("brush:")) {
|
|
const brushId = value.slice("brush:".length).trim();
|
|
|
|
if (brushId.length === 0) {
|
|
throw new Error("Visibility target brush id must be non-empty.");
|
|
}
|
|
|
|
return {
|
|
kind: "brush",
|
|
brushId
|
|
};
|
|
}
|
|
|
|
if (value.startsWith("modelInstance:")) {
|
|
const modelInstanceId = value.slice("modelInstance:".length).trim();
|
|
|
|
if (modelInstanceId.length === 0) {
|
|
throw new Error("Visibility target model instance id must be non-empty.");
|
|
}
|
|
|
|
return {
|
|
kind: "modelInstance",
|
|
modelInstanceId
|
|
};
|
|
}
|
|
|
|
throw new Error(
|
|
"Sequence visibility targets must reference a brush or model instance."
|
|
);
|
|
}
|
|
|
|
function readSceneTransitionTargetKey(value: string): {
|
|
targetSceneId: string;
|
|
targetEntryEntityId: string;
|
|
} {
|
|
const separatorIndex = value.indexOf("::");
|
|
|
|
if (separatorIndex <= 0 || separatorIndex >= value.length - 2) {
|
|
throw new Error(
|
|
"Scene transition targets must reference a scene id and Scene Entry id."
|
|
);
|
|
}
|
|
|
|
const targetSceneId = value.slice(0, separatorIndex).trim();
|
|
const targetEntryEntityId = value.slice(separatorIndex + 2).trim();
|
|
|
|
if (targetSceneId.length === 0 || targetEntryEntityId.length === 0) {
|
|
throw new Error(
|
|
"Scene transition targets must reference a scene id and Scene Entry id."
|
|
);
|
|
}
|
|
|
|
return {
|
|
targetSceneId,
|
|
targetEntryEntityId
|
|
};
|
|
}
|
|
|
|
function getDefaultTriggerVolumeLinkTrigger(
|
|
triggerOnEnter: boolean,
|
|
triggerOnExit: boolean
|
|
): InteractionTriggerKind {
|
|
if (triggerOnEnter) {
|
|
return "enter";
|
|
}
|
|
|
|
if (triggerOnExit) {
|
|
return "exit";
|
|
}
|
|
|
|
return "enter";
|
|
}
|
|
|
|
function isInteractionSourceEntity(
|
|
entity: EntityInstance | null
|
|
): entity is InteractionSourceEntity {
|
|
return (
|
|
entity !== null &&
|
|
(entity.kind === "triggerVolume" ||
|
|
entity.kind === "interactable" ||
|
|
entity.kind === "npc")
|
|
);
|
|
}
|
|
|
|
function isSoundEmitterEntity(
|
|
entity: EntityInstance | null
|
|
): entity is Extract<EntityInstance, { kind: "soundEmitter" }> {
|
|
return entity !== null && entity.kind === "soundEmitter";
|
|
}
|
|
|
|
function getDefaultInteractionLinkTrigger(
|
|
sourceEntity: InteractionSourceEntity
|
|
): InteractionTriggerKind {
|
|
return sourceEntity.kind === "triggerVolume"
|
|
? getDefaultTriggerVolumeLinkTrigger(
|
|
sourceEntity.triggerOnEnter,
|
|
sourceEntity.triggerOnExit
|
|
)
|
|
: "click";
|
|
}
|
|
|
|
function getCanonicalInteractionLinkTrigger(
|
|
sourceEntity: InteractionSourceEntity,
|
|
trigger: InteractionTriggerKind
|
|
): InteractionTriggerKind {
|
|
if (sourceEntity.kind === "triggerVolume") {
|
|
return trigger === "click"
|
|
? getDefaultInteractionLinkTrigger(sourceEntity)
|
|
: trigger;
|
|
}
|
|
|
|
return "click";
|
|
}
|
|
|
|
function getErrorMessage(error: unknown): string {
|
|
if (error instanceof Error) {
|
|
return error.message;
|
|
}
|
|
|
|
return "An unexpected error occurred.";
|
|
}
|
|
|
|
function isTextEntryTarget(target: EventTarget | null): boolean {
|
|
if (!(target instanceof HTMLElement)) {
|
|
return false;
|
|
}
|
|
|
|
return (
|
|
target instanceof HTMLInputElement ||
|
|
target instanceof HTMLTextAreaElement ||
|
|
target instanceof HTMLSelectElement ||
|
|
target.isContentEditable
|
|
);
|
|
}
|
|
|
|
function selectionCanBeDuplicated(selection: EditorSelection): boolean {
|
|
switch (selection.kind) {
|
|
case "brushes":
|
|
case "paths":
|
|
case "entities":
|
|
case "modelInstances":
|
|
return selection.ids.length > 0;
|
|
case "terrains":
|
|
return false;
|
|
case "brushFace":
|
|
case "brushEdge":
|
|
case "brushVertex":
|
|
return true;
|
|
case "pathPoint":
|
|
case "none":
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function isCommitIncrementKey(key: string): boolean {
|
|
return (
|
|
key === "ArrowUp" ||
|
|
key === "ArrowDown" ||
|
|
key === "PageUp" ||
|
|
key === "PageDown"
|
|
);
|
|
}
|
|
|
|
function blurActiveTextEntry() {
|
|
const activeElement = document.activeElement;
|
|
|
|
if (
|
|
!(activeElement instanceof HTMLElement) ||
|
|
!isTextEntryTarget(activeElement)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
activeElement.blur();
|
|
}
|
|
|
|
function sortDocumentMaterials(
|
|
materials: Record<string, MaterialDef>
|
|
): MaterialDef[] {
|
|
return Object.values(materials).sort((left, right) => {
|
|
const leftStarterIndex =
|
|
STARTER_MATERIAL_ORDER.get(left.id) ?? Number.MAX_SAFE_INTEGER;
|
|
const rightStarterIndex =
|
|
STARTER_MATERIAL_ORDER.get(right.id) ?? Number.MAX_SAFE_INTEGER;
|
|
|
|
if (leftStarterIndex !== rightStarterIndex) {
|
|
return leftStarterIndex - rightStarterIndex;
|
|
}
|
|
|
|
return left.name.localeCompare(right.name);
|
|
});
|
|
}
|
|
|
|
function getMaterialPreviewStyle(material: MaterialDef): CSSProperties {
|
|
return {
|
|
backgroundColor: material.swatchColorHex,
|
|
backgroundImage: `url("${getStarterMaterialPreviewUrl(material)}")`,
|
|
backgroundPosition: "center",
|
|
backgroundRepeat: "no-repeat",
|
|
backgroundSize: "cover"
|
|
};
|
|
}
|
|
|
|
function rotateQuarterTurns(
|
|
rotationQuarterTurns: FaceUvRotationQuarterTurns
|
|
): FaceUvRotationQuarterTurns {
|
|
return ((rotationQuarterTurns + 1) % 4) as FaceUvRotationQuarterTurns;
|
|
}
|
|
|
|
function getTransformOperationPastTense(operation: TransformOperation): string {
|
|
switch (operation) {
|
|
case "translate":
|
|
return "Moved";
|
|
case "rotate":
|
|
return "Rotated";
|
|
case "scale":
|
|
return "Scaled";
|
|
}
|
|
}
|
|
|
|
function formatRunnerFeetPosition(position: Vec3 | null): string {
|
|
if (position === null) {
|
|
return "n/a";
|
|
}
|
|
|
|
return `${position.x.toFixed(2)}, ${position.y.toFixed(2)}, ${position.z.toFixed(2)}`;
|
|
}
|
|
|
|
function formatRunnerLocomotionMode(
|
|
locomotionState: RuntimeLocomotionState | undefined
|
|
): string {
|
|
switch (locomotionState?.locomotionMode) {
|
|
case "grounded":
|
|
return "Grounded";
|
|
case "airborne":
|
|
return "Airborne";
|
|
case "flying":
|
|
return "Flying";
|
|
case "swimming":
|
|
return "Swimming";
|
|
default:
|
|
return "n/a";
|
|
}
|
|
}
|
|
|
|
function formatRunnerGait(
|
|
locomotionState: RuntimeLocomotionState | undefined
|
|
): string {
|
|
switch (locomotionState?.gait) {
|
|
case "idle":
|
|
return "Idle";
|
|
case "walk":
|
|
return "Walk";
|
|
case "sprint":
|
|
return "Sprint";
|
|
case "crouch":
|
|
return "Crouch";
|
|
default:
|
|
return "n/a";
|
|
}
|
|
}
|
|
|
|
function formatRunnerAirborneKind(
|
|
locomotionState: RuntimeLocomotionState | undefined
|
|
): string {
|
|
switch (locomotionState?.airborneKind) {
|
|
case "jumping":
|
|
return "Jumping";
|
|
case "falling":
|
|
return "Falling";
|
|
case null:
|
|
case undefined:
|
|
return "n/a";
|
|
}
|
|
}
|
|
|
|
function formatRunnerGroundContact(
|
|
locomotionState: RuntimeLocomotionState | undefined
|
|
): string {
|
|
if (locomotionState?.contact.groundNormal === null) {
|
|
return "No ground";
|
|
}
|
|
|
|
if (locomotionState === undefined) {
|
|
return "n/a";
|
|
}
|
|
|
|
const slope = locomotionState.contact.slopeDegrees;
|
|
const distance = locomotionState.contact.groundDistance;
|
|
|
|
return `${slope === null ? "?" : slope.toFixed(1)} deg @ ${distance === null ? "?" : distance.toFixed(2)}m`;
|
|
}
|
|
|
|
function formatRunnerMovementSignals(
|
|
telemetry: FirstPersonTelemetry | null
|
|
): string {
|
|
if (telemetry === null) {
|
|
return "n/a";
|
|
}
|
|
|
|
const activeSignals: string[] = [];
|
|
|
|
if (telemetry.signals.jumpStarted) {
|
|
activeSignals.push("jump");
|
|
}
|
|
|
|
if (telemetry.signals.leftGround) {
|
|
activeSignals.push("left ground");
|
|
}
|
|
|
|
if (telemetry.signals.startedFalling) {
|
|
activeSignals.push("fall");
|
|
}
|
|
|
|
if (telemetry.signals.landed) {
|
|
activeSignals.push("land");
|
|
}
|
|
|
|
if (telemetry.signals.enteredWater) {
|
|
activeSignals.push("enter water");
|
|
}
|
|
|
|
if (telemetry.signals.exitedWater) {
|
|
activeSignals.push("exit water");
|
|
}
|
|
|
|
if (telemetry.signals.wallContactStarted) {
|
|
activeSignals.push("wall");
|
|
}
|
|
|
|
if (telemetry.signals.headBump) {
|
|
activeSignals.push("head bump");
|
|
}
|
|
|
|
return activeSignals.length > 0 ? activeSignals.join(", ") : "none";
|
|
}
|
|
|
|
function formatRunnerAudioHook(telemetry: FirstPersonTelemetry | null): string {
|
|
if (telemetry === null) {
|
|
return "n/a";
|
|
}
|
|
|
|
const underwaterAmount = telemetry.hooks.audio.underwaterAmount;
|
|
|
|
if (underwaterAmount >= 0.99) {
|
|
return "submerged";
|
|
}
|
|
|
|
if (underwaterAmount <= 0.01) {
|
|
return "dry";
|
|
}
|
|
|
|
return `wet ${underwaterAmount.toFixed(2)}`;
|
|
}
|
|
|
|
function formatRunnerAnimationHook(
|
|
telemetry: FirstPersonTelemetry | null
|
|
): string {
|
|
if (telemetry === null) {
|
|
return "n/a";
|
|
}
|
|
|
|
const animationHook = telemetry.hooks.animation;
|
|
|
|
if (animationHook.locomotionMode === "airborne") {
|
|
return animationHook.airborneKind === "jumping" ? "jump" : "fall";
|
|
}
|
|
|
|
if (animationHook.locomotionMode === "swimming") {
|
|
return `swim ${animationHook.movementAmount.toFixed(2)}`;
|
|
}
|
|
|
|
return animationHook.moving
|
|
? `${animationHook.gait} ${animationHook.movementAmount.toFixed(2)}`
|
|
: "idle";
|
|
}
|
|
|
|
function formatWorldBackgroundLabel(
|
|
background: WorldBackgroundSettings
|
|
): string {
|
|
if (background.mode === "solid") {
|
|
return "Solid";
|
|
}
|
|
|
|
if (background.mode === "verticalGradient") {
|
|
return "Vertical Gradient";
|
|
}
|
|
|
|
if (background.mode === "shader") {
|
|
return "Shader Sky";
|
|
}
|
|
|
|
return "Image";
|
|
}
|
|
|
|
function getWorldBackgroundImagePreviewUrl(
|
|
background: WorldBackgroundSettings,
|
|
loadedImageAssets: Record<string, LoadedImageAsset>
|
|
): string | null {
|
|
if (background.mode !== "image" || background.assetId.trim().length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return loadedImageAssets[background.assetId]?.previewUrl ?? null;
|
|
}
|
|
|
|
function describeWorldBackground(
|
|
background: WorldBackgroundSettings,
|
|
assets: Record<string, ProjectAssetRecord>,
|
|
options: { emptyImageLabel?: string } = {}
|
|
): string {
|
|
if (background.mode === "solid") {
|
|
return background.colorHex;
|
|
}
|
|
|
|
if (background.mode === "verticalGradient") {
|
|
return `${background.topColorHex} -> ${background.bottomColorHex}`;
|
|
}
|
|
|
|
if (background.mode === "shader") {
|
|
return "Default Sky";
|
|
}
|
|
|
|
if (background.assetId.trim().length === 0) {
|
|
return options.emptyImageLabel ?? "Automatic fallback";
|
|
}
|
|
|
|
return assets[background.assetId]?.sourceName ?? background.assetId;
|
|
}
|
|
|
|
function formatAdvancedRenderingShadowTypeLabel(
|
|
type: AdvancedRenderingShadowType
|
|
): string {
|
|
switch (type) {
|
|
case "basic":
|
|
return "Basic";
|
|
case "pcf":
|
|
return "PCF";
|
|
case "pcfSoft":
|
|
return "PCF Soft";
|
|
}
|
|
}
|
|
|
|
function formatAdvancedRenderingToneMappingLabel(
|
|
mode: AdvancedRenderingToneMappingMode
|
|
): string {
|
|
switch (mode) {
|
|
case "none":
|
|
return "None";
|
|
case "linear":
|
|
return "Linear";
|
|
case "reinhard":
|
|
return "Reinhard";
|
|
case "cineon":
|
|
return "Cineon";
|
|
case "acesFilmic":
|
|
return "ACES Filmic";
|
|
}
|
|
}
|
|
|
|
function formatAdvancedRenderingWaterReflectionModeLabel(
|
|
mode: AdvancedRenderingWaterReflectionMode
|
|
): string {
|
|
switch (mode) {
|
|
case "none":
|
|
return "Nothing";
|
|
case "world":
|
|
return "World";
|
|
case "all":
|
|
return "All";
|
|
}
|
|
}
|
|
|
|
function formatBoxVolumeModeLabel(mode: BoxBrushVolumeMode): string {
|
|
switch (mode) {
|
|
case "none":
|
|
return "None";
|
|
case "water":
|
|
return "Water";
|
|
case "fog":
|
|
return "Fog";
|
|
case "light":
|
|
return "Light";
|
|
}
|
|
}
|
|
|
|
function formatBoxLightFalloffLabel(mode: BoxBrushLightFalloffMode): string {
|
|
switch (mode) {
|
|
case "linear":
|
|
return "Linear";
|
|
case "smoothstep":
|
|
return "Smoothstep";
|
|
}
|
|
}
|
|
|
|
function formatBoxVolumeRenderPathLabel(path: BoxVolumeRenderPath): string {
|
|
switch (path) {
|
|
case "performance":
|
|
return "Performance";
|
|
case "quality":
|
|
return "Quality";
|
|
}
|
|
}
|
|
|
|
const DEFAULT_BOX_VOLUME_LIGHT_SETTINGS = createDefaultBoxBrushLightSettings();
|
|
|
|
function createProjectDownloadName(projectName: string): string {
|
|
const slug = projectName
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/gu, "-")
|
|
.replace(/^-+|-+$/gu, "");
|
|
|
|
return `${slug.length > 0 ? slug : "project"}${PROJECT_PACKAGE_FILE_EXTENSION}`;
|
|
}
|
|
|
|
export function App({ store, initialStatusMessage }: AppProps) {
|
|
const editorState = useEditorStoreState(store);
|
|
const sceneList = Object.values(editorState.projectDocument.scenes);
|
|
const activeProjectScene =
|
|
editorState.projectDocument.scenes[editorState.activeSceneId];
|
|
|
|
if (activeProjectScene === undefined) {
|
|
throw new Error(
|
|
`Active scene ${editorState.activeSceneId} does not exist in the project document.`
|
|
);
|
|
}
|
|
|
|
const brushList = Object.values(editorState.document.brushes);
|
|
const terrainList = getTerrains(editorState.document.terrains);
|
|
const pathList = getScenePaths(editorState.document.paths);
|
|
const layoutMode = editorState.viewportLayoutMode;
|
|
const activePanelId = editorState.activeViewportPanelId;
|
|
const viewportToolPreview = editorState.viewportTransientState.toolPreview;
|
|
const transformSession = editorState.viewportTransientState.transformSession;
|
|
const latestActiveTransformSessionRef = useRef<ActiveTransformSession | null>(
|
|
null
|
|
);
|
|
const entityList = getEntityInstances(editorState.document.entities);
|
|
const entityDisplayList = getSortedEntityDisplayLabels(
|
|
editorState.document.entities,
|
|
editorState.document.assets
|
|
);
|
|
const primaryPlayerStart = getPrimaryEnabledPlayerStartEntity(
|
|
editorState.document.entities
|
|
);
|
|
const materialList = sortDocumentMaterials(editorState.document.materials);
|
|
const selectedBrush = getSelectedBoxBrush(editorState.selection, brushList);
|
|
const selectedTerrain = getSelectedTerrain(
|
|
editorState.selection,
|
|
terrainList
|
|
);
|
|
const selectedPath = getSelectedPath(editorState.selection, pathList);
|
|
const selectedPathPointState = getSelectedPathPointState(
|
|
editorState.selection,
|
|
selectedPath
|
|
);
|
|
const selectedPathPointIndex = selectedPathPointState?.index ?? null;
|
|
const selectedEntity = getSelectedEntity(editorState.selection, entityList);
|
|
const selectedModelInstance = getSelectedModelInstance(
|
|
editorState.selection,
|
|
Object.values(editorState.document.modelInstances)
|
|
);
|
|
const multiSelectionSummary = getMultiSelectionSummary(
|
|
editorState.selection,
|
|
editorState.activeSelectionId,
|
|
brushList,
|
|
editorState.document.modelInstances,
|
|
editorState.document.assets,
|
|
editorState.document.entities
|
|
);
|
|
const whiteboxSelectionMode = editorState.whiteboxSelectionMode;
|
|
const whiteboxSnapEnabled = editorState.whiteboxSnapEnabled;
|
|
const viewportGridVisible = editorState.viewportGridVisible;
|
|
const selectedFaceId = getSelectedBrushFaceId(editorState.selection);
|
|
const selectedEdgeId = getSelectedBrushEdgeId(editorState.selection);
|
|
const selectedVertexId = getSelectedBrushVertexId(editorState.selection);
|
|
const selectedFace =
|
|
selectedBrush !== null && selectedFaceId !== null
|
|
? selectedBrush.faces[selectedFaceId]
|
|
: null;
|
|
const selectedFaceMaterial =
|
|
selectedFace !== null && selectedFace.materialId !== null
|
|
? (editorState.document.materials[selectedFace.materialId] ?? null)
|
|
: null;
|
|
const selectedBrushSharedMaterialId =
|
|
selectedBrush === null ? null : getSharedBrushFaceMaterialId(selectedBrush);
|
|
const selectedBrushSharedMaterial =
|
|
selectedBrushSharedMaterialId === undefined ||
|
|
selectedBrushSharedMaterialId === null
|
|
? null
|
|
: (editorState.document.materials[selectedBrushSharedMaterialId] ?? null);
|
|
const selectedBrushHasMixedFaceMaterials =
|
|
selectedBrush !== null && selectedBrushSharedMaterialId === undefined;
|
|
const selectedBrushFaceIds =
|
|
selectedBrush === null ? [] : getBrushFaceIds(selectedBrush);
|
|
const selectedBrushHasMixedFaceUvs =
|
|
selectedBrush !== null
|
|
? selectedBrushFaceIds
|
|
.slice(1)
|
|
.some(
|
|
(faceId) =>
|
|
!areFaceUvStatesEqual(
|
|
selectedBrush.faces[selectedBrushFaceIds[0]].uv,
|
|
selectedBrush.faces[faceId].uv
|
|
)
|
|
)
|
|
: false;
|
|
const materialInspectorScope =
|
|
whiteboxSelectionMode === "object" && selectedBrush !== null
|
|
? "brush"
|
|
: whiteboxSelectionMode === "face" &&
|
|
selectedBrush !== null &&
|
|
selectedFaceId !== null &&
|
|
selectedFace !== null
|
|
? "face"
|
|
: null;
|
|
const materialInspectorMaterialId =
|
|
materialInspectorScope === "brush"
|
|
? selectedBrushSharedMaterialId
|
|
: materialInspectorScope === "face"
|
|
? (selectedFace?.materialId ?? null)
|
|
: null;
|
|
const materialInspectorMaterial =
|
|
materialInspectorScope === "brush"
|
|
? selectedBrushSharedMaterial
|
|
: materialInspectorScope === "face"
|
|
? selectedFaceMaterial
|
|
: null;
|
|
const materialInspectorActiveLabel =
|
|
materialInspectorScope === "brush" ? "Active Scope" : "Active Face";
|
|
const materialInspectorActiveValue =
|
|
materialInspectorScope === "brush"
|
|
? "Whole Solid"
|
|
: selectedFaceId === null
|
|
? null
|
|
: selectedBrush === null
|
|
? selectedFaceId
|
|
: getBrushFaceLabel(selectedBrush, selectedFaceId);
|
|
const materialInspectorMaterialSummary =
|
|
materialInspectorScope === "brush" && selectedBrushHasMixedFaceMaterials
|
|
? "Mixed across faces"
|
|
: (materialInspectorMaterial?.name ??
|
|
(materialInspectorMaterialId === null
|
|
? "Fallback face color"
|
|
: (materialInspectorMaterialId ?? "Fallback face color")));
|
|
const materialInspectorUvState =
|
|
materialInspectorScope === "brush" && selectedBrush !== null
|
|
? selectedBrush.faces[selectedBrushFaceIds[0]].uv
|
|
: materialInspectorScope === "face"
|
|
? (selectedFace?.uv ?? null)
|
|
: null;
|
|
const selectedModelAsset =
|
|
selectedModelInstance !== null
|
|
? (editorState.document.assets[selectedModelInstance.assetId] ?? null)
|
|
: null;
|
|
const selectedModelAssetRecord =
|
|
selectedModelAsset !== null && selectedModelAsset.kind === "model"
|
|
? selectedModelAsset
|
|
: null;
|
|
const selectedTerrainBounds =
|
|
selectedTerrain === null ? null : getTerrainBounds(selectedTerrain);
|
|
const selectedTerrainFootprint =
|
|
selectedTerrain === null
|
|
? null
|
|
: {
|
|
width: getTerrainFootprintWidth(selectedTerrain),
|
|
depth: getTerrainFootprintDepth(selectedTerrain)
|
|
};
|
|
const selectedTerrainHeightRange =
|
|
selectedTerrainBounds === null
|
|
? null
|
|
: {
|
|
min: selectedTerrainBounds.min.y,
|
|
max: selectedTerrainBounds.max.y
|
|
};
|
|
const selectedPlayerStart =
|
|
selectedEntity?.kind === "playerStart" ? selectedEntity : null;
|
|
const selectedCameraRig =
|
|
selectedEntity?.kind === "cameraRig" ? selectedEntity : null;
|
|
const selectedSoundEmitter = isSoundEmitterEntity(selectedEntity)
|
|
? selectedEntity
|
|
: null;
|
|
const selectedSoundEmitterAsset =
|
|
selectedSoundEmitter === null
|
|
? null
|
|
: selectedSoundEmitter.audioAssetId === null
|
|
? null
|
|
: (editorState.document.assets[selectedSoundEmitter.audioAssetId] ??
|
|
null);
|
|
const selectedSoundEmitterAudioAssetRecord =
|
|
selectedSoundEmitterAsset !== null &&
|
|
selectedSoundEmitterAsset.kind === "audio"
|
|
? selectedSoundEmitterAsset
|
|
: null;
|
|
const selectedTriggerVolume =
|
|
selectedEntity?.kind === "triggerVolume" ? selectedEntity : null;
|
|
const selectedSceneEntry =
|
|
selectedEntity?.kind === "sceneEntry" ? selectedEntity : null;
|
|
const selectedNpc = selectedEntity?.kind === "npc" ? selectedEntity : null;
|
|
const selectedNpcAsset =
|
|
selectedNpc === null
|
|
? null
|
|
: selectedNpc.modelAssetId === null
|
|
? null
|
|
: (editorState.document.assets[selectedNpc.modelAssetId] ?? null);
|
|
const selectedNpcModelAssetRecord =
|
|
selectedNpcAsset !== null && selectedNpcAsset.kind === "model"
|
|
? selectedNpcAsset
|
|
: null;
|
|
const selectedNpcActorUsages =
|
|
selectedNpc === null
|
|
? []
|
|
: listNpcActorUsages(editorState.projectDocument, selectedNpc.actorId);
|
|
const selectedNpcOtherActorUsages =
|
|
selectedNpc === null
|
|
? []
|
|
: selectedNpcActorUsages.filter(
|
|
(usage) =>
|
|
!(
|
|
usage.sceneId === editorState.activeSceneId &&
|
|
usage.entityId === selectedNpc.id
|
|
)
|
|
);
|
|
const selectedNpcSameSceneActorUsages =
|
|
selectedNpc === null
|
|
? []
|
|
: selectedNpcOtherActorUsages.filter(
|
|
(usage) => usage.sceneId === editorState.activeSceneId
|
|
);
|
|
const selectedNpcOtherSceneActorUsages =
|
|
selectedNpc === null
|
|
? []
|
|
: selectedNpcOtherActorUsages.filter(
|
|
(usage) => usage.sceneId !== editorState.activeSceneId
|
|
);
|
|
const projectScheduleTargetOptions = listProjectScheduleTargetOptions(
|
|
editorState.projectDocument
|
|
);
|
|
const interactionControlTargetOptions =
|
|
listProjectInteractionControlTargetOptions(projectScheduleTargetOptions);
|
|
const selectedTeleportTarget =
|
|
selectedEntity?.kind === "teleportTarget" ? selectedEntity : null;
|
|
const selectedInteractable =
|
|
selectedEntity?.kind === "interactable" ? selectedEntity : null;
|
|
const projectAssetList = Object.values(editorState.document.assets);
|
|
const projectAssetDisplayList = [...projectAssetList].sort(
|
|
(left, right) =>
|
|
left.kind.localeCompare(right.kind) ||
|
|
left.sourceName.localeCompare(right.sourceName)
|
|
);
|
|
const modelAssetList = projectAssetList.filter(isModelAsset);
|
|
const imageAssetList = projectAssetList.filter(isImageAsset);
|
|
const audioAssetList = projectAssetList.filter(isAudioAsset);
|
|
const selectedPointLight =
|
|
selectedEntity?.kind === "pointLight" ? selectedEntity : null;
|
|
const selectedSpotLight =
|
|
selectedEntity?.kind === "spotLight" ? selectedEntity : null;
|
|
const modelInstanceDisplayList = getSortedModelInstanceDisplayLabels(
|
|
editorState.document.modelInstances,
|
|
editorState.document.assets
|
|
);
|
|
const projectSequenceList = getProjectSequences(
|
|
editorState.projectDocument.sequences
|
|
);
|
|
const projectImpulseSequenceList = projectSequenceList.filter(
|
|
(sequence) => getProjectSequenceImpulseSteps(sequence).length > 0
|
|
);
|
|
const npcDialogueSequenceTargetOptions = Object.values(
|
|
editorState.projectDocument.scenes
|
|
)
|
|
.flatMap((scene) =>
|
|
Object.values(scene.entities).flatMap((entity) => {
|
|
if (entity.kind !== "npc" || entity.dialogues.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const npcLabel = (entity.name?.trim() || entity.id).trim();
|
|
|
|
return [
|
|
{
|
|
npcEntityId: entity.id,
|
|
label: `${scene.name} · ${npcLabel}`,
|
|
defaultDialogueId: entity.defaultDialogueId,
|
|
dialogues: entity.dialogues.map((dialogue) => ({
|
|
dialogueId: dialogue.id,
|
|
label: dialogue.title
|
|
}))
|
|
}
|
|
];
|
|
})
|
|
)
|
|
.sort((left, right) => left.label.localeCompare(right.label));
|
|
const selectedInteractionSource = isInteractionSourceEntity(selectedEntity)
|
|
? selectedEntity
|
|
: null;
|
|
const selectedTriggerVolumeLinks =
|
|
selectedTriggerVolume === null
|
|
? []
|
|
: getInteractionLinksForSource(
|
|
editorState.document.interactionLinks,
|
|
selectedTriggerVolume.id
|
|
);
|
|
const selectedInteractableLinks =
|
|
selectedInteractable === null
|
|
? []
|
|
: getInteractionLinksForSource(
|
|
editorState.document.interactionLinks,
|
|
selectedInteractable.id
|
|
);
|
|
const selectedNpcLinks =
|
|
selectedNpc === null
|
|
? []
|
|
: getInteractionLinksForSource(
|
|
editorState.document.interactionLinks,
|
|
selectedNpc.id
|
|
);
|
|
const sceneTargetOptions = sceneList.map((scene) => ({
|
|
id: scene.id,
|
|
name: scene.name
|
|
}));
|
|
const sceneEntryOptionsBySceneId = Object.fromEntries(
|
|
sceneTargetOptions.map(({ id }) => {
|
|
const scene = editorState.projectDocument.scenes[id];
|
|
const sceneEntries = getSortedEntityDisplayLabels(
|
|
scene?.entities ?? {},
|
|
editorState.projectDocument.assets
|
|
).filter(({ entity }) => entity.kind === "sceneEntry");
|
|
|
|
return [
|
|
id,
|
|
sceneEntries as Array<{
|
|
entity: Extract<EntityInstance, { kind: "sceneEntry" }>;
|
|
label: string;
|
|
}>
|
|
];
|
|
})
|
|
) as Record<
|
|
string,
|
|
Array<{
|
|
entity: Extract<EntityInstance, { kind: "sceneEntry" }>;
|
|
label: string;
|
|
}>
|
|
>;
|
|
const teleportTargetOptions = entityDisplayList.filter(
|
|
({ entity }) => entity.kind === "teleportTarget"
|
|
);
|
|
const sceneTransitionTargetOptions = sceneTargetOptions.flatMap((scene) =>
|
|
(sceneEntryOptionsBySceneId[scene.id] ?? []).map(({ entity, label }) => ({
|
|
targetKey: `${scene.id}::${entity.id}`,
|
|
label: `${scene.name} · ${label}`
|
|
}))
|
|
);
|
|
const soundEmitterOptions = entityDisplayList.filter(
|
|
({ entity }) => entity.kind === "soundEmitter"
|
|
) as Array<{
|
|
entity: Extract<EntityInstance, { kind: "soundEmitter" }>;
|
|
label: string;
|
|
}>;
|
|
const cameraRigActorOptions = Array.from(
|
|
new Set(
|
|
entityList
|
|
.filter(
|
|
(entity): entity is Extract<EntityInstance, { kind: "npc" }> =>
|
|
entity.kind === "npc"
|
|
)
|
|
.map((entity) => entity.actorId)
|
|
)
|
|
).sort((left, right) => left.localeCompare(right));
|
|
const cameraRigEntityTargetOptions = entityDisplayList.filter(
|
|
({ entity }) =>
|
|
entity.id !== selectedCameraRig?.id && entity.kind !== "cameraRig"
|
|
);
|
|
const cameraRigPathOptions = pathList.map((path, index) => ({
|
|
path,
|
|
label: getScenePathLabel(path, index)
|
|
}));
|
|
const getCameraRigPathById = (pathId: string) =>
|
|
editorState.document.paths[pathId] ?? cameraRigPathOptions[0]?.path ?? null;
|
|
const getDefaultCameraRigRailMappingDraft = (pathId: string) => {
|
|
const authoredPath = getCameraRigPathById(pathId);
|
|
const startPoint =
|
|
authoredPath?.points[0]?.position ?? DEFAULT_CAMERA_RIG_TRACK_START_POINT;
|
|
const endPoint =
|
|
authoredPath?.points[authoredPath.points.length - 1]?.position ??
|
|
DEFAULT_CAMERA_RIG_TRACK_END_POINT;
|
|
|
|
return {
|
|
trackStartPoint: createVec3Draft(startPoint),
|
|
trackEndPoint: createVec3Draft(endPoint),
|
|
railStartProgress: String(DEFAULT_CAMERA_RIG_RAIL_START_PROGRESS),
|
|
railEndProgress: String(DEFAULT_CAMERA_RIG_RAIL_END_PROGRESS)
|
|
};
|
|
};
|
|
const playableSoundEmitterOptions = soundEmitterOptions.filter(
|
|
({ entity }) => {
|
|
if (entity.audioAssetId === null) {
|
|
return false;
|
|
}
|
|
|
|
return editorState.document.assets[entity.audioAssetId]?.kind === "audio";
|
|
}
|
|
);
|
|
const visibilityBrushOptions = brushList.map((brush, brushIndex) => ({
|
|
brush,
|
|
label: getBrushLabel(brush, brushIndex)
|
|
}));
|
|
const sequenceVisibilityTargetOptions = [
|
|
...visibilityBrushOptions.map(({ brush, label }) => ({
|
|
targetKey: `brush:${brush.id}`,
|
|
label: `Whitebox Solid · ${label}`
|
|
})),
|
|
...modelInstanceDisplayList.map(({ modelInstance, label }) => ({
|
|
targetKey: `modelInstance:${modelInstance.id}`,
|
|
label: `Model Instance · ${label}`
|
|
}))
|
|
];
|
|
|
|
const [projectNameDraft, setProjectNameDraft] = useState(
|
|
editorState.projectDocument.name
|
|
);
|
|
const [sceneNameDraft, setSceneNameDraft] = useState(
|
|
editorState.document.name
|
|
);
|
|
const [sceneLoadingHeadlineDraft, setSceneLoadingHeadlineDraft] = useState(
|
|
activeProjectScene.loadingScreen.headline ?? ""
|
|
);
|
|
const [sceneLoadingDescriptionDraft, setSceneLoadingDescriptionDraft] =
|
|
useState(activeProjectScene.loadingScreen.description ?? "");
|
|
const [brushNameDraft, setBrushNameDraft] = useState("");
|
|
const [pathNameDraft, setPathNameDraft] = useState("");
|
|
const [entityNameDraft, setEntityNameDraft] = useState("");
|
|
const [modelInstanceNameDraft, setModelInstanceNameDraft] = useState("");
|
|
const [pathPointDrafts, setPathPointDrafts] = useState<Vec3Draft[]>([]);
|
|
const [positionDraft, setPositionDraft] = useState(
|
|
createVec3Draft(DEFAULT_BOX_BRUSH_CENTER)
|
|
);
|
|
const [rotationDraft, setRotationDraft] = useState(
|
|
createVec3Draft(DEFAULT_BOX_BRUSH_ROTATION_DEGREES)
|
|
);
|
|
const [sizeDraft, setSizeDraft] = useState(
|
|
createVec3Draft(DEFAULT_BOX_BRUSH_SIZE)
|
|
);
|
|
const [boxVolumeModeDraft, setBoxVolumeModeDraft] =
|
|
useState<BoxBrushVolumeMode>("none");
|
|
const [boxVolumeWaterColorDraft, setBoxVolumeWaterColorDraft] =
|
|
useState("#4da6d9");
|
|
const [
|
|
boxVolumeWaterSurfaceOpacityDraft,
|
|
setBoxVolumeWaterSurfaceOpacityDraft
|
|
] = useState("0.55");
|
|
const [boxVolumeWaterWaveStrengthDraft, setBoxVolumeWaterWaveStrengthDraft] =
|
|
useState("0.35");
|
|
const [
|
|
boxVolumeWaterFoamContactLimitDraft,
|
|
setBoxVolumeWaterFoamContactLimitDraft
|
|
] = useState(String(DEFAULT_BOX_BRUSH_WATER_FOAM_CONTACT_LIMIT));
|
|
const [
|
|
boxVolumeWaterSurfaceDisplacementEnabledDraft,
|
|
setBoxVolumeWaterSurfaceDisplacementEnabledDraft
|
|
] = useState(false);
|
|
const [boxVolumeFogColorDraft, setBoxVolumeFogColorDraft] =
|
|
useState("#9cb7c7");
|
|
const [boxVolumeFogDensityDraft, setBoxVolumeFogDensityDraft] =
|
|
useState("0.08");
|
|
const [boxVolumeFogPaddingDraft, setBoxVolumeFogPaddingDraft] =
|
|
useState("0.2");
|
|
const [boxVolumeLightColorDraft, setBoxVolumeLightColorDraft] = useState(
|
|
DEFAULT_BOX_VOLUME_LIGHT_SETTINGS.colorHex
|
|
);
|
|
const [boxVolumeLightIntensityDraft, setBoxVolumeLightIntensityDraft] =
|
|
useState(String(DEFAULT_BOX_VOLUME_LIGHT_SETTINGS.intensity));
|
|
const [boxVolumeLightPaddingDraft, setBoxVolumeLightPaddingDraft] = useState(
|
|
String(DEFAULT_BOX_VOLUME_LIGHT_SETTINGS.padding)
|
|
);
|
|
const [boxVolumeLightFalloffDraft, setBoxVolumeLightFalloffDraft] =
|
|
useState<BoxBrushLightFalloffMode>(
|
|
DEFAULT_BOX_VOLUME_LIGHT_SETTINGS.falloff
|
|
);
|
|
const [whiteboxSnapStepDraft, setWhiteboxSnapStepDraft] = useState(
|
|
String(editorState.whiteboxSnapStep)
|
|
);
|
|
const [uvOffsetDraft, setUvOffsetDraft] = useState(
|
|
createVec2Draft(createDefaultFaceUvState().offset)
|
|
);
|
|
const [uvScaleDraft, setUvScaleDraft] = useState(
|
|
createVec2Draft(createDefaultFaceUvState().scale)
|
|
);
|
|
const [entityPositionDraft, setEntityPositionDraft] = useState(
|
|
createVec3Draft(DEFAULT_ENTITY_POSITION)
|
|
);
|
|
const [cameraRigRigTypeDraft, setCameraRigRigTypeDraft] =
|
|
useState<CameraRigType>("fixed");
|
|
const [cameraRigPathIdDraft, setCameraRigPathIdDraft] = useState("");
|
|
const [cameraRigRailPlacementModeDraft, setCameraRigRailPlacementModeDraft] =
|
|
useState<CameraRigRailPlacementMode>(
|
|
DEFAULT_CAMERA_RIG_RAIL_PLACEMENT_MODE
|
|
);
|
|
const [cameraRigTrackStartPointDraft, setCameraRigTrackStartPointDraft] =
|
|
useState(createVec3Draft(DEFAULT_CAMERA_RIG_TRACK_START_POINT));
|
|
const [cameraRigTrackEndPointDraft, setCameraRigTrackEndPointDraft] =
|
|
useState(createVec3Draft(DEFAULT_CAMERA_RIG_TRACK_END_POINT));
|
|
const [cameraRigRailStartProgressDraft, setCameraRigRailStartProgressDraft] =
|
|
useState(String(DEFAULT_CAMERA_RIG_RAIL_START_PROGRESS));
|
|
const [cameraRigRailEndProgressDraft, setCameraRigRailEndProgressDraft] =
|
|
useState(String(DEFAULT_CAMERA_RIG_RAIL_END_PROGRESS));
|
|
const [cameraRigPriorityDraft, setCameraRigPriorityDraft] = useState(
|
|
String(DEFAULT_CAMERA_RIG_PRIORITY)
|
|
);
|
|
const [cameraRigDefaultActiveDraft, setCameraRigDefaultActiveDraft] =
|
|
useState(DEFAULT_CAMERA_RIG_DEFAULT_ACTIVE);
|
|
const [cameraRigTargetKindDraft, setCameraRigTargetKindDraft] =
|
|
useState<CameraRigTargetKind>("player");
|
|
const [cameraRigTargetActorIdDraft, setCameraRigTargetActorIdDraft] =
|
|
useState("");
|
|
const [cameraRigTargetEntityIdDraft, setCameraRigTargetEntityIdDraft] =
|
|
useState("");
|
|
const [cameraRigTargetWorldPointDraft, setCameraRigTargetWorldPointDraft] =
|
|
useState(createVec3Draft(DEFAULT_ENTITY_POSITION));
|
|
const [cameraRigTargetOffsetDraft, setCameraRigTargetOffsetDraft] = useState(
|
|
createVec3Draft(DEFAULT_CAMERA_RIG_TARGET_OFFSET)
|
|
);
|
|
const [cameraRigTransitionModeDraft, setCameraRigTransitionModeDraft] =
|
|
useState<CameraRigTransitionMode>(DEFAULT_CAMERA_RIG_TRANSITION_MODE);
|
|
const [
|
|
cameraRigTransitionDurationDraft,
|
|
setCameraRigTransitionDurationDraft
|
|
] = useState(String(DEFAULT_CAMERA_RIG_TRANSITION_DURATION_SECONDS));
|
|
const [cameraRigLookAroundEnabledDraft, setCameraRigLookAroundEnabledDraft] =
|
|
useState(DEFAULT_CAMERA_RIG_LOOK_AROUND_ENABLED);
|
|
const [
|
|
cameraRigLookAroundYawLimitDraft,
|
|
setCameraRigLookAroundYawLimitDraft
|
|
] = useState(String(DEFAULT_CAMERA_RIG_LOOK_AROUND_YAW_LIMIT_DEGREES));
|
|
const [
|
|
cameraRigLookAroundPitchLimitDraft,
|
|
setCameraRigLookAroundPitchLimitDraft
|
|
] = useState(String(DEFAULT_CAMERA_RIG_LOOK_AROUND_PITCH_LIMIT_DEGREES));
|
|
const [
|
|
cameraRigLookAroundRecenterSpeedDraft,
|
|
setCameraRigLookAroundRecenterSpeedDraft
|
|
] = useState(String(DEFAULT_CAMERA_RIG_LOOK_AROUND_RECENTER_SPEED));
|
|
const [pointLightColorDraft, setPointLightColorDraft] = useState(
|
|
DEFAULT_POINT_LIGHT_COLOR_HEX
|
|
);
|
|
const [pointLightIntensityDraft, setPointLightIntensityDraft] = useState(
|
|
String(DEFAULT_POINT_LIGHT_INTENSITY)
|
|
);
|
|
const [pointLightDistanceDraft, setPointLightDistanceDraft] = useState(
|
|
String(DEFAULT_POINT_LIGHT_DISTANCE)
|
|
);
|
|
const [spotLightColorDraft, setSpotLightColorDraft] = useState(
|
|
DEFAULT_SPOT_LIGHT_COLOR_HEX
|
|
);
|
|
const [spotLightIntensityDraft, setSpotLightIntensityDraft] = useState(
|
|
String(DEFAULT_SPOT_LIGHT_INTENSITY)
|
|
);
|
|
const [spotLightDistanceDraft, setSpotLightDistanceDraft] = useState(
|
|
String(DEFAULT_SPOT_LIGHT_DISTANCE)
|
|
);
|
|
const [spotLightAngleDraft, setSpotLightAngleDraft] = useState(
|
|
String(DEFAULT_SPOT_LIGHT_ANGLE_DEGREES)
|
|
);
|
|
const [spotLightDirectionDraft, setSpotLightDirectionDraft] = useState(
|
|
createVec3Draft(DEFAULT_SPOT_LIGHT_DIRECTION)
|
|
);
|
|
const [playerStartYawDraft, setPlayerStartYawDraft] = useState("0");
|
|
const [playerStartNavigationModeDraft, setPlayerStartNavigationModeDraft] =
|
|
useState<PlayerStartNavigationMode>(DEFAULT_PLAYER_START_NAVIGATION_MODE);
|
|
const [
|
|
playerStartInteractionReachDraft,
|
|
setPlayerStartInteractionReachDraft
|
|
] = useState(String(DEFAULT_PLAYER_START_INTERACTION_REACH_METERS));
|
|
const [
|
|
playerStartInteractionAngleDraft,
|
|
setPlayerStartInteractionAngleDraft
|
|
] = useState(String(DEFAULT_PLAYER_START_INTERACTION_ANGLE_DEGREES));
|
|
const [
|
|
playerStartAllowLookInputTargetSwitchDraft,
|
|
setPlayerStartAllowLookInputTargetSwitchDraft
|
|
] = useState(DEFAULT_PLAYER_START_ALLOW_LOOK_INPUT_TARGET_SWITCH);
|
|
const [
|
|
playerStartTargetButtonCyclesActiveTargetDraft,
|
|
setPlayerStartTargetButtonCyclesActiveTargetDraft
|
|
] = useState(DEFAULT_PLAYER_START_TARGET_BUTTON_CYCLES_ACTIVE_TARGET);
|
|
const [
|
|
playerStartMovementTemplateDraft,
|
|
setPlayerStartMovementTemplateDraft
|
|
] = useState<PlayerStartMovementTemplate>(
|
|
createPlayerStartMovementTemplate()
|
|
);
|
|
const [
|
|
playerStartMovementTemplateNumberDraft,
|
|
setPlayerStartMovementTemplateNumberDraft
|
|
] = useState<PlayerStartMovementTemplateNumberDraft>(
|
|
createPlayerStartMovementTemplateNumberDraft(
|
|
createPlayerStartMovementTemplate()
|
|
)
|
|
);
|
|
const [playerStartColliderModeDraft, setPlayerStartColliderModeDraft] =
|
|
useState<PlayerStartColliderMode>("capsule");
|
|
const [playerStartEyeHeightDraft, setPlayerStartEyeHeightDraft] = useState(
|
|
String(DEFAULT_PLAYER_START_EYE_HEIGHT)
|
|
);
|
|
const [playerStartCapsuleRadiusDraft, setPlayerStartCapsuleRadiusDraft] =
|
|
useState(String(DEFAULT_PLAYER_START_CAPSULE_RADIUS));
|
|
const [playerStartCapsuleHeightDraft, setPlayerStartCapsuleHeightDraft] =
|
|
useState(String(DEFAULT_PLAYER_START_CAPSULE_HEIGHT));
|
|
const [playerStartBoxSizeDraft, setPlayerStartBoxSizeDraft] = useState(
|
|
createVec3Draft(DEFAULT_PLAYER_START_BOX_SIZE)
|
|
);
|
|
const [playerStartInputBindingsDraft, setPlayerStartInputBindingsDraft] =
|
|
useState<PlayerStartInputBindings>(createPlayerStartInputBindings());
|
|
const [
|
|
playerStartKeyboardCaptureAction,
|
|
setPlayerStartKeyboardCaptureAction
|
|
] = useState<PlayerStartInputAction | null>(null);
|
|
const [soundEmitterAudioAssetIdDraft, setSoundEmitterAudioAssetIdDraft] =
|
|
useState(DEFAULT_SOUND_EMITTER_AUDIO_ASSET_ID ?? "");
|
|
const [soundEmitterVolumeDraft, setSoundEmitterVolumeDraft] = useState(
|
|
String(DEFAULT_SOUND_EMITTER_VOLUME)
|
|
);
|
|
const [soundEmitterRefDistanceDraft, setSoundEmitterRefDistanceDraft] =
|
|
useState(String(DEFAULT_SOUND_EMITTER_REF_DISTANCE));
|
|
const [soundEmitterMaxDistanceDraft, setSoundEmitterMaxDistanceDraft] =
|
|
useState(String(DEFAULT_SOUND_EMITTER_MAX_DISTANCE));
|
|
const [soundEmitterAutoplayDraft, setSoundEmitterAutoplayDraft] =
|
|
useState(false);
|
|
const [soundEmitterLoopDraft, setSoundEmitterLoopDraft] = useState(false);
|
|
const [triggerVolumeSizeDraft, setTriggerVolumeSizeDraft] = useState(
|
|
createVec3Draft(DEFAULT_TRIGGER_VOLUME_SIZE)
|
|
);
|
|
const [sceneEntryYawDraft, setSceneEntryYawDraft] = useState(
|
|
String(DEFAULT_SCENE_ENTRY_YAW_DEGREES)
|
|
);
|
|
const [npcActorIdDraft, setNpcActorIdDraft] = useState("");
|
|
const [npcYawDraft, setNpcYawDraft] = useState(
|
|
String(DEFAULT_NPC_YAW_DEGREES)
|
|
);
|
|
const [npcColliderModeDraft, setNpcColliderModeDraft] =
|
|
useState<PlayerStartColliderMode>(createNpcColliderSettings().mode);
|
|
const [npcEyeHeightDraft, setNpcEyeHeightDraft] = useState(
|
|
String(createNpcColliderSettings().eyeHeight)
|
|
);
|
|
const [npcCapsuleRadiusDraft, setNpcCapsuleRadiusDraft] = useState(
|
|
String(createNpcColliderSettings().capsuleRadius)
|
|
);
|
|
const [npcCapsuleHeightDraft, setNpcCapsuleHeightDraft] = useState(
|
|
String(createNpcColliderSettings().capsuleHeight)
|
|
);
|
|
const [npcBoxSizeDraft, setNpcBoxSizeDraft] = useState(
|
|
createVec3Draft(createNpcColliderSettings().boxSize)
|
|
);
|
|
const [npcModelAssetIdDraft, setNpcModelAssetIdDraft] = useState(
|
|
DEFAULT_NPC_MODEL_ASSET_ID ?? ""
|
|
);
|
|
const [teleportTargetYawDraft, setTeleportTargetYawDraft] = useState(
|
|
String(DEFAULT_TELEPORT_TARGET_YAW_DEGREES)
|
|
);
|
|
const [interactableRadiusDraft, setInteractableRadiusDraft] = useState(
|
|
String(DEFAULT_INTERACTABLE_RADIUS)
|
|
);
|
|
const [interactablePromptDraft, setInteractablePromptDraft] = useState(
|
|
DEFAULT_INTERACTABLE_PROMPT
|
|
);
|
|
const [interactableEnabledDraft, setInteractableEnabledDraft] =
|
|
useState(true);
|
|
const [modelPositionDraft, setModelPositionDraft] = useState(
|
|
createVec3Draft(DEFAULT_MODEL_INSTANCE_POSITION)
|
|
);
|
|
const [modelRotationDraft, setModelRotationDraft] = useState(
|
|
createVec3Draft(DEFAULT_MODEL_INSTANCE_ROTATION_DEGREES)
|
|
);
|
|
const [modelScaleDraft, setModelScaleDraft] = useState(
|
|
createVec3Draft(DEFAULT_MODEL_INSTANCE_SCALE)
|
|
);
|
|
const [armedTerrainBrushTool, setArmedTerrainBrushTool] =
|
|
useState<TerrainBrushTool | null>(null);
|
|
const [activeTerrainPaintLayerIndex, setActiveTerrainPaintLayerIndex] =
|
|
useState(0);
|
|
const [terrainBrushSettings, setTerrainBrushSettings] = useState(
|
|
createDefaultTerrainBrushSettings()
|
|
);
|
|
const [terrainSampleCountXDraft, setTerrainSampleCountXDraft] = useState("9");
|
|
const [terrainSampleCountZDraft, setTerrainSampleCountZDraft] = useState("9");
|
|
const [terrainCellSizeDraft, setTerrainCellSizeDraft] = useState("1");
|
|
const activeTerrainBrushState: ArmedTerrainBrushState | null =
|
|
selectedTerrain === null || armedTerrainBrushTool === null
|
|
? null
|
|
: armedTerrainBrushTool === "paint"
|
|
? {
|
|
terrainId: selectedTerrain.id,
|
|
tool: "paint",
|
|
layerIndex: clampTerrainPaintLayerIndex(
|
|
activeTerrainPaintLayerIndex
|
|
),
|
|
radius: terrainBrushSettings.radius,
|
|
strength: terrainBrushSettings.strength,
|
|
falloff: terrainBrushSettings.falloff
|
|
}
|
|
: {
|
|
terrainId: selectedTerrain.id,
|
|
tool: armedTerrainBrushTool,
|
|
radius: terrainBrushSettings.radius,
|
|
strength: terrainBrushSettings.strength,
|
|
falloff: terrainBrushSettings.falloff
|
|
};
|
|
const resolvedTerrainPaintLayerIndex = clampTerrainPaintLayerIndex(
|
|
activeTerrainPaintLayerIndex
|
|
);
|
|
const selectedTerrainActivePaintLayer =
|
|
selectedTerrain?.layers[resolvedTerrainPaintLayerIndex] ?? null;
|
|
const selectedTerrainActivePaintMaterial =
|
|
selectedTerrainActivePaintLayer?.materialId === null ||
|
|
selectedTerrainActivePaintLayer === null
|
|
? null
|
|
: (editorState.document.materials[
|
|
selectedTerrainActivePaintLayer.materialId
|
|
] ?? null);
|
|
const [ambientLightIntensityDraft, setAmbientLightIntensityDraft] = useState(
|
|
String(editorState.document.world.ambientLight.intensity)
|
|
);
|
|
const [sunLightIntensityDraft, setSunLightIntensityDraft] = useState(
|
|
String(editorState.document.world.sunLight.intensity)
|
|
);
|
|
const [projectTimeStartDayNumberDraft, setProjectTimeStartDayNumberDraft] =
|
|
useState(String(editorState.projectDocument.time.startDayNumber));
|
|
const [projectTimeStartTimeOfDayDraft, setProjectTimeStartTimeOfDayDraft] =
|
|
useState(String(editorState.projectDocument.time.startTimeOfDayHours));
|
|
const [
|
|
projectTimeDayLengthMinutesDraft,
|
|
setProjectTimeDayLengthMinutesDraft
|
|
] = useState(String(editorState.projectDocument.time.dayLengthMinutes));
|
|
const [
|
|
projectTimeSunriseTimeOfDayDraft,
|
|
setProjectTimeSunriseTimeOfDayDraft
|
|
] = useState(String(editorState.projectDocument.time.sunriseTimeOfDayHours));
|
|
const [projectTimeSunsetTimeOfDayDraft, setProjectTimeSunsetTimeOfDayDraft] =
|
|
useState(String(editorState.projectDocument.time.sunsetTimeOfDayHours));
|
|
const [
|
|
projectTimeDawnDurationHoursDraft,
|
|
setProjectTimeDawnDurationHoursDraft
|
|
] = useState(String(editorState.projectDocument.time.dawnDurationHours));
|
|
const [
|
|
projectTimeDuskDurationHoursDraft,
|
|
setProjectTimeDuskDurationHoursDraft
|
|
] = useState(String(editorState.projectDocument.time.duskDurationHours));
|
|
const [schedulePaneOpen, setSchedulePaneOpen] = useState(false);
|
|
const [sequencerMode, setSequencerMode] = useState<"timeline" | "sequence">(
|
|
"timeline"
|
|
);
|
|
const [selectedScheduleRoutineId, setSelectedScheduleRoutineId] = useState<
|
|
string | null
|
|
>(null);
|
|
const [selectedNpcDialogueId, setSelectedNpcDialogueId] = useState<
|
|
string | null
|
|
>(null);
|
|
const [selectedSequenceId, setSelectedSequenceId] = useState<string | null>(
|
|
null
|
|
);
|
|
const [
|
|
worldDawnAmbientIntensityFactorDraft,
|
|
setWorldDawnAmbientIntensityFactorDraft
|
|
] = useState(
|
|
String(editorState.document.world.timeOfDay.dawn.ambientIntensityFactor)
|
|
);
|
|
const [
|
|
worldDawnLightIntensityFactorDraft,
|
|
setWorldDawnLightIntensityFactorDraft
|
|
] = useState(
|
|
String(editorState.document.world.timeOfDay.dawn.lightIntensityFactor)
|
|
);
|
|
const [
|
|
worldDawnBackgroundEnvironmentIntensityDraft,
|
|
setWorldDawnBackgroundEnvironmentIntensityDraft
|
|
] = useState(
|
|
editorState.document.world.timeOfDay.dawn.background.mode === "image"
|
|
? String(
|
|
editorState.document.world.timeOfDay.dawn.background
|
|
.environmentIntensity
|
|
)
|
|
: String(DEFAULT_TIME_PHASE_IMAGE_ENVIRONMENT_INTENSITY)
|
|
);
|
|
const [
|
|
worldDuskAmbientIntensityFactorDraft,
|
|
setWorldDuskAmbientIntensityFactorDraft
|
|
] = useState(
|
|
String(editorState.document.world.timeOfDay.dusk.ambientIntensityFactor)
|
|
);
|
|
const [
|
|
worldDuskLightIntensityFactorDraft,
|
|
setWorldDuskLightIntensityFactorDraft
|
|
] = useState(
|
|
String(editorState.document.world.timeOfDay.dusk.lightIntensityFactor)
|
|
);
|
|
const [
|
|
worldDuskBackgroundEnvironmentIntensityDraft,
|
|
setWorldDuskBackgroundEnvironmentIntensityDraft
|
|
] = useState(
|
|
editorState.document.world.timeOfDay.dusk.background.mode === "image"
|
|
? String(
|
|
editorState.document.world.timeOfDay.dusk.background
|
|
.environmentIntensity
|
|
)
|
|
: String(DEFAULT_TIME_PHASE_IMAGE_ENVIRONMENT_INTENSITY)
|
|
);
|
|
const [
|
|
worldNightAmbientIntensityFactorDraft,
|
|
setWorldNightAmbientIntensityFactorDraft
|
|
] = useState(
|
|
String(editorState.document.world.timeOfDay.night.ambientIntensityFactor)
|
|
);
|
|
const [
|
|
worldNightLightIntensityFactorDraft,
|
|
setWorldNightLightIntensityFactorDraft
|
|
] = useState(
|
|
String(editorState.document.world.timeOfDay.night.lightIntensityFactor)
|
|
);
|
|
const [
|
|
worldNightBackgroundEnvironmentIntensityDraft,
|
|
setWorldNightBackgroundEnvironmentIntensityDraft
|
|
] = useState(
|
|
editorState.document.world.timeOfDay.night.background.mode === "image"
|
|
? String(
|
|
editorState.document.world.timeOfDay.night.background
|
|
.environmentIntensity
|
|
)
|
|
: String(DEFAULT_NIGHT_IMAGE_ENVIRONMENT_INTENSITY)
|
|
);
|
|
const [
|
|
backgroundEnvironmentIntensityDraft,
|
|
setBackgroundEnvironmentIntensityDraft
|
|
] = useState(
|
|
editorState.document.world.background.mode === "image"
|
|
? String(editorState.document.world.background.environmentIntensity)
|
|
: "0.5"
|
|
);
|
|
const [
|
|
advancedRenderingShadowBiasDraft,
|
|
setAdvancedRenderingShadowBiasDraft
|
|
] = useState(
|
|
String(editorState.document.world.advancedRendering.shadows.bias)
|
|
);
|
|
const [
|
|
advancedRenderingAmbientOcclusionIntensityDraft,
|
|
setAdvancedRenderingAmbientOcclusionIntensityDraft
|
|
] = useState(
|
|
String(
|
|
editorState.document.world.advancedRendering.ambientOcclusion.intensity
|
|
)
|
|
);
|
|
const [
|
|
advancedRenderingAmbientOcclusionRadiusDraft,
|
|
setAdvancedRenderingAmbientOcclusionRadiusDraft
|
|
] = useState(
|
|
String(editorState.document.world.advancedRendering.ambientOcclusion.radius)
|
|
);
|
|
const [
|
|
advancedRenderingAmbientOcclusionSamplesDraft,
|
|
setAdvancedRenderingAmbientOcclusionSamplesDraft
|
|
] = useState(
|
|
String(
|
|
editorState.document.world.advancedRendering.ambientOcclusion.samples
|
|
)
|
|
);
|
|
const [
|
|
advancedRenderingBloomIntensityDraft,
|
|
setAdvancedRenderingBloomIntensityDraft
|
|
] = useState(
|
|
String(editorState.document.world.advancedRendering.bloom.intensity)
|
|
);
|
|
const [
|
|
advancedRenderingBloomThresholdDraft,
|
|
setAdvancedRenderingBloomThresholdDraft
|
|
] = useState(
|
|
String(editorState.document.world.advancedRendering.bloom.threshold)
|
|
);
|
|
const [
|
|
advancedRenderingBloomRadiusDraft,
|
|
setAdvancedRenderingBloomRadiusDraft
|
|
] = useState(
|
|
String(editorState.document.world.advancedRendering.bloom.radius)
|
|
);
|
|
const [
|
|
advancedRenderingToneMappingExposureDraft,
|
|
setAdvancedRenderingToneMappingExposureDraft
|
|
] = useState(
|
|
String(editorState.document.world.advancedRendering.toneMapping.exposure)
|
|
);
|
|
const [
|
|
advancedRenderingDepthOfFieldFocusDistanceDraft,
|
|
setAdvancedRenderingDepthOfFieldFocusDistanceDraft
|
|
] = useState(
|
|
String(
|
|
editorState.document.world.advancedRendering.depthOfField.focusDistance
|
|
)
|
|
);
|
|
const [
|
|
advancedRenderingDepthOfFieldFocalLengthDraft,
|
|
setAdvancedRenderingDepthOfFieldFocalLengthDraft
|
|
] = useState(
|
|
String(
|
|
editorState.document.world.advancedRendering.depthOfField.focalLength
|
|
)
|
|
);
|
|
const [
|
|
advancedRenderingDepthOfFieldBokehScaleDraft,
|
|
setAdvancedRenderingDepthOfFieldBokehScaleDraft
|
|
] = useState(
|
|
String(editorState.document.world.advancedRendering.depthOfField.bokehScale)
|
|
);
|
|
const [
|
|
advancedRenderingWhiteboxBevelEdgeWidthDraft,
|
|
setAdvancedRenderingWhiteboxBevelEdgeWidthDraft
|
|
] = useState(
|
|
String(editorState.document.world.advancedRendering.whiteboxBevel.edgeWidth)
|
|
);
|
|
const [
|
|
advancedRenderingWhiteboxBevelNormalStrengthDraft,
|
|
setAdvancedRenderingWhiteboxBevelNormalStrengthDraft
|
|
] = useState(
|
|
String(
|
|
editorState.document.world.advancedRendering.whiteboxBevel.normalStrength
|
|
)
|
|
);
|
|
const [statusMessage, setStatusMessage] = useState(
|
|
initialStatusMessage ?? "Slice 3.5 advanced rendering ready."
|
|
);
|
|
const [assetStatusMessage, setAssetStatusMessage] = useState<string | null>(
|
|
null
|
|
);
|
|
const [hoveredAssetId, setHoveredAssetId] = useState<string | null>(null);
|
|
const [hoveredViewportPanelId, setHoveredViewportPanelId] =
|
|
useState<ViewportPanelId | null>(null);
|
|
const [addMenuPosition, setAddMenuPosition] =
|
|
useState<HierarchicalMenuPosition | null>(null);
|
|
const [activeNavigationMode, setActiveNavigationMode] =
|
|
useState<RuntimeNavigationMode>("thirdPerson");
|
|
const [projectAssetStorage, setProjectAssetStorage] =
|
|
useState<ProjectAssetStorage | null>(null);
|
|
const [projectAssetStorageReady, setProjectAssetStorageReady] =
|
|
useState(false);
|
|
const [runtimeScene, setRuntimeScene] =
|
|
useState<RuntimeSceneDefinition | null>(null);
|
|
const [runtimeSceneId, setRuntimeSceneId] = useState<string | null>(null);
|
|
const [runtimeSceneName, setRuntimeSceneName] = useState<string | null>(null);
|
|
const [runtimeSceneLoadingScreen, setRuntimeSceneLoadingScreen] =
|
|
useState<SceneLoadingScreenSettings | null>(null);
|
|
const [runtimeGlobalState, setRuntimeGlobalState] =
|
|
useState<RuntimeGlobalState>(
|
|
createDefaultRuntimeGlobalState(editorState.projectDocument.time)
|
|
);
|
|
const [runtimeMessage, setRuntimeMessage] = useState<string | null>(null);
|
|
const [editorSimulationSnapshot, setEditorSimulationSnapshot] =
|
|
useState<EditorSimulationUiSnapshot>(() =>
|
|
createInitialEditorSimulationUiSnapshot(editorState.projectDocument.time)
|
|
);
|
|
const [firstPersonTelemetry, setFirstPersonTelemetry] =
|
|
useState<FirstPersonTelemetry | null>(null);
|
|
const [runtimeInteractionPrompt, setRuntimeInteractionPrompt] =
|
|
useState<RuntimeInteractionPrompt | null>(null);
|
|
const [loadedModelAssets, setLoadedModelAssets] = useState<
|
|
Record<string, LoadedModelAsset>
|
|
>({});
|
|
const [loadedImageAssets, setLoadedImageAssets] = useState<
|
|
Record<string, LoadedImageAsset>
|
|
>({});
|
|
const [loadedAudioAssets, setLoadedAudioAssets] = useState<
|
|
Record<string, LoadedAudioAsset>
|
|
>({});
|
|
const [focusRequest, setFocusRequest] = useState<{
|
|
id: number;
|
|
selection: EditorSelection;
|
|
panelId: ViewportPanelId;
|
|
}>({
|
|
id: 0,
|
|
panelId: "topLeft",
|
|
selection: {
|
|
kind: "none"
|
|
}
|
|
});
|
|
const importProjectInputRef = useRef<HTMLInputElement | null>(null);
|
|
const importModelInputRef = useRef<HTMLInputElement | null>(null);
|
|
const importBackgroundImageInputRef = useRef<HTMLInputElement | null>(null);
|
|
const importAudioInputRef = useRef<HTMLInputElement | null>(null);
|
|
const viewportPanelsRef = useRef<HTMLDivElement | null>(null);
|
|
const editorMainRegionRef = useRef<HTMLDivElement | null>(null);
|
|
const loadedModelAssetsRef = useRef<Record<string, LoadedModelAsset>>({});
|
|
const loadedImageAssetsRef = useRef<Record<string, LoadedImageAsset>>({});
|
|
const loadedAudioAssetsRef = useRef<Record<string, LoadedAudioAsset>>({});
|
|
const previousProjectAssetsRef = useRef<Record<string, ProjectAssetRecord>>(
|
|
editorState.document.assets
|
|
);
|
|
const deletedProjectAssetStorageRecordsRef = useRef<
|
|
Record<string, ProjectAssetStorageRecord>
|
|
>({});
|
|
const autosaveControllerRef = useRef<EditorAutosaveController | null>(null);
|
|
const editorSimulationControllerRef =
|
|
useRef<EditorSimulationController | null>(null);
|
|
const lastAutosaveErrorRef = useRef<string | null>(null);
|
|
const viewportQuadSplitRef = useRef(editorState.viewportQuadSplit);
|
|
const lastPointerPositionRef = useRef<HierarchicalMenuPosition>({
|
|
x: Math.round(window.innerWidth * 0.5),
|
|
y: Math.round(window.innerHeight * 0.5)
|
|
});
|
|
const [viewportQuadResizeMode, setViewportQuadResizeMode] =
|
|
useState<ViewportQuadResizeMode | null>(null);
|
|
const [schedulePaneHeight, setSchedulePaneHeight] = useState(
|
|
DEFAULT_SCHEDULE_PANE_HEIGHT
|
|
);
|
|
const schedulePaneHeightRef = useRef(DEFAULT_SCHEDULE_PANE_HEIGHT);
|
|
const schedulePaneResizeStartRef = useRef<{
|
|
startY: number;
|
|
startHeight: number;
|
|
} | null>(null);
|
|
const [schedulePaneResizeActive, setSchedulePaneResizeActive] =
|
|
useState(false);
|
|
if (editorSimulationControllerRef.current === null) {
|
|
editorSimulationControllerRef.current = new EditorSimulationController();
|
|
}
|
|
const editorSimulationController = editorSimulationControllerRef.current;
|
|
const documentValidation = validateSceneDocument(editorState.document);
|
|
const projectValidation = validateProjectDocument(
|
|
editorState.projectDocument
|
|
);
|
|
const activeSceneProjectDiagnostics = projectValidation.diagnostics.filter(
|
|
(diagnostic) =>
|
|
diagnostic.path?.startsWith(`scenes.${editorState.activeSceneId}.`) &&
|
|
(diagnostic.code === "missing-scene-exit-target-scene" ||
|
|
diagnostic.code === "missing-scene-exit-target-entry" ||
|
|
diagnostic.code === "scene-exit-target-entry-kind-mismatch")
|
|
);
|
|
const authoredNavigationMode: RuntimeNavigationMode =
|
|
primaryPlayerStart?.navigationMode ?? "thirdPerson";
|
|
const runValidation = validateRuntimeSceneBuild(editorState.document, {
|
|
navigationMode: authoredNavigationMode,
|
|
loadedModelAssets
|
|
});
|
|
const diagnostics = [
|
|
...documentValidation.errors,
|
|
...documentValidation.warnings,
|
|
...activeSceneProjectDiagnostics,
|
|
...runValidation.errors,
|
|
...runValidation.warnings
|
|
];
|
|
const blockingDiagnostics = diagnostics.filter(
|
|
(diagnostic) => diagnostic.severity === "error"
|
|
);
|
|
const warningDiagnostics = diagnostics.filter(
|
|
(diagnostic) => diagnostic.severity === "warning"
|
|
);
|
|
const documentStatusLabel =
|
|
documentValidation.errors.length === 0
|
|
? "Valid"
|
|
: formatDiagnosticCount(documentValidation.errors.length, "error");
|
|
const lastCommandLabel = editorState.lastCommandLabel ?? "No commands yet";
|
|
const runReadyLabel =
|
|
blockingDiagnostics.length > 0
|
|
? "Blocked"
|
|
: authoredNavigationMode === "firstPerson"
|
|
? "Ready for First Person"
|
|
: "Ready for Third Person";
|
|
const editorSimulationClock =
|
|
editorSimulationSnapshot.clock ??
|
|
createRuntimeClockState(editorState.projectDocument.time);
|
|
const editorSimulationPlaying = editorSimulationSnapshot.playing;
|
|
const editorSimulationOverrideActive =
|
|
editorSimulationSnapshot.overrideActive;
|
|
const editorSimulationMessage = editorSimulationSnapshot.message;
|
|
const editorSimulationTimeState = resolveRuntimeTimeState(
|
|
editorState.projectDocument.time,
|
|
editorSimulationClock
|
|
);
|
|
const advancedRendering = editorState.document.world.advancedRendering;
|
|
const hoveredAsset =
|
|
hoveredAssetId === null
|
|
? null
|
|
: (editorState.document.assets[hoveredAssetId] ?? null);
|
|
const hoveredAssetStatusMessage =
|
|
hoveredAsset === null ? null : formatAssetHoverStatus(hoveredAsset);
|
|
const selectedTransformTarget = resolveTransformTarget(
|
|
editorState.document,
|
|
editorState.selection,
|
|
whiteboxSelectionMode,
|
|
editorState.activeSelectionId
|
|
).target;
|
|
const canTranslateSelectedTarget =
|
|
selectedTransformTarget !== null &&
|
|
supportsTransformOperation(selectedTransformTarget, "translate");
|
|
const canRotateSelectedTarget =
|
|
selectedTransformTarget !== null &&
|
|
supportsTransformOperation(selectedTransformTarget, "rotate");
|
|
const canScaleSelectedTarget =
|
|
selectedTransformTarget !== null &&
|
|
supportsTransformOperation(selectedTransformTarget, "scale");
|
|
const surfaceSnapTransformTarget =
|
|
transformSession.kind === "active"
|
|
? transformSession.target
|
|
: selectedTransformTarget;
|
|
const canSurfaceSnapTransformTarget =
|
|
surfaceSnapTransformTarget !== null &&
|
|
supportsTransformSurfaceSnapTarget(surfaceSnapTransformTarget);
|
|
const whiteboxSnapStep = editorState.whiteboxSnapStep;
|
|
const whiteboxVectorInputStep = getWhiteboxInputStep(
|
|
whiteboxSnapEnabled,
|
|
whiteboxSnapStep
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (transformSession.kind === "none") {
|
|
latestActiveTransformSessionRef.current = null;
|
|
return;
|
|
}
|
|
|
|
if (
|
|
latestActiveTransformSessionRef.current === null ||
|
|
latestActiveTransformSessionRef.current.id !== transformSession.id
|
|
) {
|
|
latestActiveTransformSessionRef.current = transformSession;
|
|
}
|
|
}, [transformSession]);
|
|
|
|
const clampSchedulePaneHeight = (nextHeight: number) => {
|
|
const editorMainRegionHeight =
|
|
editorMainRegionRef.current?.clientHeight ??
|
|
DEFAULT_SCHEDULE_PANE_HEIGHT + MIN_VIEWPORT_REGION_HEIGHT;
|
|
const maxHeight = Math.max(
|
|
96,
|
|
editorMainRegionHeight - MIN_VIEWPORT_REGION_HEIGHT
|
|
);
|
|
const minHeight = Math.min(MIN_SCHEDULE_PANE_HEIGHT, maxHeight);
|
|
|
|
return Math.min(Math.max(nextHeight, minHeight), maxHeight);
|
|
};
|
|
|
|
const commitSchedulePaneHeight = (
|
|
nextHeight: number | ((previousHeight: number) => number)
|
|
) => {
|
|
setSchedulePaneHeight((previousHeight) => {
|
|
const resolvedHeight =
|
|
typeof nextHeight === "function"
|
|
? nextHeight(previousHeight)
|
|
: nextHeight;
|
|
const clampedHeight = clampSchedulePaneHeight(resolvedHeight);
|
|
schedulePaneHeightRef.current = clampedHeight;
|
|
return clampedHeight;
|
|
});
|
|
};
|
|
|
|
useEffect(() => {
|
|
setPlayerStartKeyboardCaptureAction(null);
|
|
}, [selectedPlayerStart?.id]);
|
|
|
|
useEffect(() => {
|
|
if (selectedNpc === null) {
|
|
setSelectedNpcDialogueId(null);
|
|
return;
|
|
}
|
|
|
|
if (
|
|
selectedNpcDialogueId !== null &&
|
|
selectedNpc.dialogues.some(
|
|
(dialogue) => dialogue.id === selectedNpcDialogueId
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
setSelectedNpcDialogueId(
|
|
selectedNpc.defaultDialogueId ?? selectedNpc.dialogues[0]?.id ?? null
|
|
);
|
|
}, [selectedNpc, selectedNpcDialogueId]);
|
|
|
|
useEffect(() => {
|
|
if (
|
|
selectedSequenceId !== null &&
|
|
editorState.projectDocument.sequences.sequences[selectedSequenceId] !==
|
|
undefined
|
|
) {
|
|
return;
|
|
}
|
|
|
|
setSelectedSequenceId(projectSequenceList[0]?.id ?? null);
|
|
}, [
|
|
editorState.projectDocument.sequences.sequences,
|
|
projectSequenceList,
|
|
selectedSequenceId
|
|
]);
|
|
|
|
useEffect(() => {
|
|
schedulePaneHeightRef.current = schedulePaneHeight;
|
|
}, [schedulePaneHeight]);
|
|
|
|
useEffect(() => {
|
|
if (!schedulePaneOpen) {
|
|
setSchedulePaneResizeActive(false);
|
|
schedulePaneResizeStartRef.current = null;
|
|
return;
|
|
}
|
|
|
|
const syncSchedulePaneHeight = () => {
|
|
commitSchedulePaneHeight(schedulePaneHeightRef.current);
|
|
};
|
|
|
|
syncSchedulePaneHeight();
|
|
window.addEventListener("resize", syncSchedulePaneHeight);
|
|
|
|
return () => {
|
|
window.removeEventListener("resize", syncSchedulePaneHeight);
|
|
};
|
|
}, [schedulePaneOpen]);
|
|
|
|
useEffect(() => {
|
|
if (!schedulePaneOpen || !schedulePaneResizeActive) {
|
|
return;
|
|
}
|
|
|
|
const previousCursor = document.body.style.cursor;
|
|
const previousUserSelect = document.body.style.userSelect;
|
|
document.body.style.cursor = "row-resize";
|
|
document.body.style.userSelect = "none";
|
|
|
|
const handlePointerMove = (event: globalThis.PointerEvent) => {
|
|
const resizeStart = schedulePaneResizeStartRef.current;
|
|
|
|
if (resizeStart === null) {
|
|
return;
|
|
}
|
|
|
|
commitSchedulePaneHeight(
|
|
resizeStart.startHeight - (event.clientY - resizeStart.startY)
|
|
);
|
|
};
|
|
|
|
const stopSchedulePaneResize = () => {
|
|
schedulePaneResizeStartRef.current = null;
|
|
setSchedulePaneResizeActive(false);
|
|
};
|
|
|
|
window.addEventListener("pointermove", handlePointerMove);
|
|
window.addEventListener("pointerup", stopSchedulePaneResize);
|
|
window.addEventListener("pointercancel", stopSchedulePaneResize);
|
|
|
|
return () => {
|
|
document.body.style.cursor = previousCursor;
|
|
document.body.style.userSelect = previousUserSelect;
|
|
window.removeEventListener("pointermove", handlePointerMove);
|
|
window.removeEventListener("pointerup", stopSchedulePaneResize);
|
|
window.removeEventListener("pointercancel", stopSchedulePaneResize);
|
|
};
|
|
}, [schedulePaneOpen, schedulePaneResizeActive]);
|
|
|
|
useEffect(() => {
|
|
if (playerStartKeyboardCaptureAction === null) {
|
|
return;
|
|
}
|
|
|
|
const handleWindowKeyCapture = (event: globalThis.KeyboardEvent) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
if (event.repeat) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
event.code === "Escape" &&
|
|
playerStartKeyboardCaptureAction !== "clearTarget"
|
|
) {
|
|
setPlayerStartKeyboardCaptureAction(null);
|
|
setStatusMessage("Cancelled Player Start key capture.");
|
|
return;
|
|
}
|
|
|
|
const capturedCode = event.code.trim();
|
|
|
|
if (capturedCode.length === 0) {
|
|
return;
|
|
}
|
|
|
|
handlePlayerStartKeyboardBindingChange(
|
|
playerStartKeyboardCaptureAction,
|
|
capturedCode
|
|
);
|
|
setPlayerStartKeyboardCaptureAction(null);
|
|
setStatusMessage(
|
|
`Bound ${getPlayerStartInputActionLabel(playerStartKeyboardCaptureAction)} to ${formatPlayerStartKeyboardBindingLabel(capturedCode)}.`
|
|
);
|
|
};
|
|
|
|
const handleWindowPointerCapture = (event: globalThis.PointerEvent) => {
|
|
const capturedCode = getPlayerStartMouseBindingCodeForButton(
|
|
event.button
|
|
);
|
|
|
|
if (capturedCode === null) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
handlePlayerStartKeyboardBindingChange(
|
|
playerStartKeyboardCaptureAction,
|
|
capturedCode
|
|
);
|
|
setPlayerStartKeyboardCaptureAction(null);
|
|
setStatusMessage(
|
|
`Bound ${getPlayerStartInputActionLabel(playerStartKeyboardCaptureAction)} to ${formatPlayerStartKeyboardBindingLabel(capturedCode)}.`
|
|
);
|
|
};
|
|
|
|
window.addEventListener("keydown", handleWindowKeyCapture, true);
|
|
window.addEventListener("pointerdown", handleWindowPointerCapture, true);
|
|
|
|
return () => {
|
|
window.removeEventListener("keydown", handleWindowKeyCapture, true);
|
|
window.removeEventListener(
|
|
"pointerdown",
|
|
handleWindowPointerCapture,
|
|
true
|
|
);
|
|
};
|
|
}, [playerStartKeyboardCaptureAction]);
|
|
|
|
useEffect(() => {
|
|
setProjectNameDraft(editorState.projectDocument.name);
|
|
}, [editorState.projectDocument.name]);
|
|
|
|
useEffect(() => {
|
|
setSceneNameDraft(editorState.document.name);
|
|
}, [editorState.document.name]);
|
|
|
|
useEffect(() => {
|
|
setWhiteboxSnapStepDraft(String(editorState.whiteboxSnapStep));
|
|
}, [editorState.activeSceneId, editorState.whiteboxSnapStep]);
|
|
|
|
useEffect(() => {
|
|
setSceneLoadingHeadlineDraft(
|
|
activeProjectScene.loadingScreen.headline ?? ""
|
|
);
|
|
setSceneLoadingDescriptionDraft(
|
|
activeProjectScene.loadingScreen.description ?? ""
|
|
);
|
|
}, [
|
|
activeProjectScene.id,
|
|
activeProjectScene.loadingScreen.headline,
|
|
activeProjectScene.loadingScreen.description
|
|
]);
|
|
|
|
useEffect(() => {
|
|
setBrushNameDraft(selectedBrush?.name ?? "");
|
|
}, [selectedBrush]);
|
|
|
|
useEffect(() => {
|
|
setPathNameDraft(selectedPath?.name ?? "");
|
|
}, [selectedPath]);
|
|
|
|
useEffect(() => {
|
|
setEntityNameDraft(selectedEntity?.name ?? "");
|
|
}, [selectedEntity]);
|
|
|
|
useEffect(() => {
|
|
setModelInstanceNameDraft(selectedModelInstance?.name ?? "");
|
|
}, [selectedModelInstance]);
|
|
|
|
useEffect(() => {
|
|
setPathPointDrafts(
|
|
selectedPath === null
|
|
? []
|
|
: selectedPath.points.map((point) => createVec3Draft(point.position))
|
|
);
|
|
}, [selectedPath]);
|
|
|
|
useEffect(() => {
|
|
if (selectedBrush === null) {
|
|
setPositionDraft(createVec3Draft(DEFAULT_BOX_BRUSH_CENTER));
|
|
setRotationDraft(createVec3Draft(DEFAULT_BOX_BRUSH_ROTATION_DEGREES));
|
|
setSizeDraft(createVec3Draft(DEFAULT_BOX_BRUSH_SIZE));
|
|
setBoxVolumeModeDraft("none");
|
|
setBoxVolumeWaterFoamContactLimitDraft(
|
|
String(DEFAULT_BOX_BRUSH_WATER_FOAM_CONTACT_LIMIT)
|
|
);
|
|
setBoxVolumeWaterSurfaceDisplacementEnabledDraft(false);
|
|
setBoxVolumeLightColorDraft(DEFAULT_BOX_VOLUME_LIGHT_SETTINGS.colorHex);
|
|
setBoxVolumeLightIntensityDraft(
|
|
String(DEFAULT_BOX_VOLUME_LIGHT_SETTINGS.intensity)
|
|
);
|
|
setBoxVolumeLightPaddingDraft(
|
|
String(DEFAULT_BOX_VOLUME_LIGHT_SETTINGS.padding)
|
|
);
|
|
setBoxVolumeLightFalloffDraft(DEFAULT_BOX_VOLUME_LIGHT_SETTINGS.falloff);
|
|
return;
|
|
}
|
|
|
|
setPositionDraft(createVec3Draft(selectedBrush.center));
|
|
setRotationDraft(createVec3Draft(selectedBrush.rotationDegrees));
|
|
setSizeDraft(createVec3Draft(selectedBrush.size));
|
|
|
|
setBoxVolumeModeDraft(selectedBrush.volume.mode);
|
|
|
|
if (selectedBrush.volume.mode === "water") {
|
|
setBoxVolumeWaterColorDraft(selectedBrush.volume.water.colorHex);
|
|
setBoxVolumeWaterSurfaceOpacityDraft(
|
|
String(selectedBrush.volume.water.surfaceOpacity)
|
|
);
|
|
setBoxVolumeWaterWaveStrengthDraft(
|
|
String(selectedBrush.volume.water.waveStrength)
|
|
);
|
|
setBoxVolumeWaterFoamContactLimitDraft(
|
|
String(selectedBrush.volume.water.foamContactLimit)
|
|
);
|
|
setBoxVolumeWaterSurfaceDisplacementEnabledDraft(
|
|
selectedBrush.volume.water.surfaceDisplacementEnabled
|
|
);
|
|
}
|
|
|
|
if (selectedBrush.volume.mode === "fog") {
|
|
setBoxVolumeFogColorDraft(selectedBrush.volume.fog.colorHex);
|
|
setBoxVolumeFogDensityDraft(String(selectedBrush.volume.fog.density));
|
|
setBoxVolumeFogPaddingDraft(String(selectedBrush.volume.fog.padding));
|
|
}
|
|
|
|
if (selectedBrush.volume.mode === "light") {
|
|
setBoxVolumeLightColorDraft(selectedBrush.volume.light.colorHex);
|
|
setBoxVolumeLightIntensityDraft(
|
|
String(selectedBrush.volume.light.intensity)
|
|
);
|
|
setBoxVolumeLightPaddingDraft(String(selectedBrush.volume.light.padding));
|
|
setBoxVolumeLightFalloffDraft(selectedBrush.volume.light.falloff);
|
|
}
|
|
}, [selectedBrush]);
|
|
|
|
useEffect(() => {
|
|
if (
|
|
whiteboxSelectionMode === "object" &&
|
|
selectedBrush !== null &&
|
|
selectedFace === null
|
|
) {
|
|
setUvOffsetDraft(
|
|
createMaybeMixedVec2Draft(
|
|
selectedBrushFaceIds.map(
|
|
(faceId) => selectedBrush.faces[faceId].uv.offset
|
|
)
|
|
)
|
|
);
|
|
setUvScaleDraft(
|
|
createMaybeMixedVec2Draft(
|
|
selectedBrushFaceIds.map(
|
|
(faceId) => selectedBrush.faces[faceId].uv.scale
|
|
)
|
|
)
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (selectedFace === null) {
|
|
const defaultUvState = createDefaultFaceUvState();
|
|
setUvOffsetDraft(createVec2Draft(defaultUvState.offset));
|
|
setUvScaleDraft(createVec2Draft(defaultUvState.scale));
|
|
return;
|
|
}
|
|
|
|
setUvOffsetDraft(createVec2Draft(selectedFace.uv.offset));
|
|
setUvScaleDraft(createVec2Draft(selectedFace.uv.scale));
|
|
}, [
|
|
selectedBrush,
|
|
selectedBrushFaceIds,
|
|
selectedFace,
|
|
whiteboxSelectionMode
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (selectedEntity === null) {
|
|
setEntityPositionDraft(createVec3Draft(DEFAULT_ENTITY_POSITION));
|
|
setCameraRigRigTypeDraft("fixed");
|
|
setCameraRigPathIdDraft("");
|
|
setCameraRigRailPlacementModeDraft(
|
|
DEFAULT_CAMERA_RIG_RAIL_PLACEMENT_MODE
|
|
);
|
|
setCameraRigTrackStartPointDraft(
|
|
createVec3Draft(DEFAULT_CAMERA_RIG_TRACK_START_POINT)
|
|
);
|
|
setCameraRigTrackEndPointDraft(
|
|
createVec3Draft(DEFAULT_CAMERA_RIG_TRACK_END_POINT)
|
|
);
|
|
setCameraRigRailStartProgressDraft(
|
|
String(DEFAULT_CAMERA_RIG_RAIL_START_PROGRESS)
|
|
);
|
|
setCameraRigRailEndProgressDraft(
|
|
String(DEFAULT_CAMERA_RIG_RAIL_END_PROGRESS)
|
|
);
|
|
setCameraRigPriorityDraft(String(DEFAULT_CAMERA_RIG_PRIORITY));
|
|
setCameraRigDefaultActiveDraft(DEFAULT_CAMERA_RIG_DEFAULT_ACTIVE);
|
|
setCameraRigTargetKindDraft("player");
|
|
setCameraRigTargetActorIdDraft("");
|
|
setCameraRigTargetEntityIdDraft("");
|
|
setCameraRigTargetWorldPointDraft(
|
|
createVec3Draft(DEFAULT_ENTITY_POSITION)
|
|
);
|
|
setCameraRigTargetOffsetDraft(
|
|
createVec3Draft(DEFAULT_CAMERA_RIG_TARGET_OFFSET)
|
|
);
|
|
setCameraRigTransitionModeDraft(DEFAULT_CAMERA_RIG_TRANSITION_MODE);
|
|
setCameraRigTransitionDurationDraft(
|
|
String(DEFAULT_CAMERA_RIG_TRANSITION_DURATION_SECONDS)
|
|
);
|
|
setCameraRigLookAroundEnabledDraft(
|
|
DEFAULT_CAMERA_RIG_LOOK_AROUND_ENABLED
|
|
);
|
|
setCameraRigLookAroundYawLimitDraft(
|
|
String(DEFAULT_CAMERA_RIG_LOOK_AROUND_YAW_LIMIT_DEGREES)
|
|
);
|
|
setCameraRigLookAroundPitchLimitDraft(
|
|
String(DEFAULT_CAMERA_RIG_LOOK_AROUND_PITCH_LIMIT_DEGREES)
|
|
);
|
|
setCameraRigLookAroundRecenterSpeedDraft(
|
|
String(DEFAULT_CAMERA_RIG_LOOK_AROUND_RECENTER_SPEED)
|
|
);
|
|
setPointLightColorDraft(DEFAULT_POINT_LIGHT_COLOR_HEX);
|
|
setPointLightIntensityDraft(String(DEFAULT_POINT_LIGHT_INTENSITY));
|
|
setPointLightDistanceDraft(String(DEFAULT_POINT_LIGHT_DISTANCE));
|
|
setSpotLightColorDraft(DEFAULT_SPOT_LIGHT_COLOR_HEX);
|
|
setSpotLightIntensityDraft(String(DEFAULT_SPOT_LIGHT_INTENSITY));
|
|
setSpotLightDistanceDraft(String(DEFAULT_SPOT_LIGHT_DISTANCE));
|
|
setSpotLightAngleDraft(String(DEFAULT_SPOT_LIGHT_ANGLE_DEGREES));
|
|
setSpotLightDirectionDraft(createVec3Draft(DEFAULT_SPOT_LIGHT_DIRECTION));
|
|
setPlayerStartYawDraft("0");
|
|
setPlayerStartNavigationModeDraft(DEFAULT_PLAYER_START_NAVIGATION_MODE);
|
|
setPlayerStartInteractionReachDraft(
|
|
String(DEFAULT_PLAYER_START_INTERACTION_REACH_METERS)
|
|
);
|
|
setPlayerStartInteractionAngleDraft(
|
|
String(DEFAULT_PLAYER_START_INTERACTION_ANGLE_DEGREES)
|
|
);
|
|
setPlayerStartAllowLookInputTargetSwitchDraft(
|
|
DEFAULT_PLAYER_START_ALLOW_LOOK_INPUT_TARGET_SWITCH
|
|
);
|
|
setPlayerStartTargetButtonCyclesActiveTargetDraft(
|
|
DEFAULT_PLAYER_START_TARGET_BUTTON_CYCLES_ACTIVE_TARGET
|
|
);
|
|
setPlayerStartMovementTemplateDraft(createPlayerStartMovementTemplate());
|
|
setPlayerStartMovementTemplateNumberDraft(
|
|
createPlayerStartMovementTemplateNumberDraft(
|
|
createPlayerStartMovementTemplate()
|
|
)
|
|
);
|
|
setPlayerStartColliderModeDraft("capsule");
|
|
setPlayerStartEyeHeightDraft(String(DEFAULT_PLAYER_START_EYE_HEIGHT));
|
|
setPlayerStartCapsuleRadiusDraft(
|
|
String(DEFAULT_PLAYER_START_CAPSULE_RADIUS)
|
|
);
|
|
setPlayerStartCapsuleHeightDraft(
|
|
String(DEFAULT_PLAYER_START_CAPSULE_HEIGHT)
|
|
);
|
|
setPlayerStartBoxSizeDraft(
|
|
createVec3Draft(DEFAULT_PLAYER_START_BOX_SIZE)
|
|
);
|
|
setPlayerStartInputBindingsDraft(createPlayerStartInputBindings());
|
|
setSoundEmitterAudioAssetIdDraft(
|
|
DEFAULT_SOUND_EMITTER_AUDIO_ASSET_ID ?? ""
|
|
);
|
|
setSoundEmitterVolumeDraft(String(DEFAULT_SOUND_EMITTER_VOLUME));
|
|
setSoundEmitterRefDistanceDraft(
|
|
String(DEFAULT_SOUND_EMITTER_REF_DISTANCE)
|
|
);
|
|
setSoundEmitterMaxDistanceDraft(
|
|
String(DEFAULT_SOUND_EMITTER_MAX_DISTANCE)
|
|
);
|
|
setSoundEmitterAutoplayDraft(false);
|
|
setSoundEmitterLoopDraft(false);
|
|
setTriggerVolumeSizeDraft(createVec3Draft(DEFAULT_TRIGGER_VOLUME_SIZE));
|
|
setSceneEntryYawDraft(String(DEFAULT_SCENE_ENTRY_YAW_DEGREES));
|
|
setNpcActorIdDraft("");
|
|
setNpcYawDraft(String(DEFAULT_NPC_YAW_DEGREES));
|
|
setNpcColliderModeDraft(createNpcColliderSettings().mode);
|
|
setNpcEyeHeightDraft(String(createNpcColliderSettings().eyeHeight));
|
|
setNpcCapsuleRadiusDraft(
|
|
String(createNpcColliderSettings().capsuleRadius)
|
|
);
|
|
setNpcCapsuleHeightDraft(
|
|
String(createNpcColliderSettings().capsuleHeight)
|
|
);
|
|
setNpcBoxSizeDraft(createVec3Draft(createNpcColliderSettings().boxSize));
|
|
setNpcModelAssetIdDraft(DEFAULT_NPC_MODEL_ASSET_ID ?? "");
|
|
setTeleportTargetYawDraft(String(DEFAULT_TELEPORT_TARGET_YAW_DEGREES));
|
|
setInteractableRadiusDraft(String(DEFAULT_INTERACTABLE_RADIUS));
|
|
setInteractablePromptDraft(DEFAULT_INTERACTABLE_PROMPT);
|
|
setInteractableEnabledDraft(true);
|
|
return;
|
|
}
|
|
|
|
const selectedEntityPosition =
|
|
selectedEntity.kind === "cameraRig"
|
|
? (resolveCameraRigDocumentPosition(
|
|
selectedEntity,
|
|
editorState.document.entities,
|
|
editorState.document.paths,
|
|
{
|
|
fallbackToPathStart: true
|
|
}
|
|
) ?? DEFAULT_ENTITY_POSITION)
|
|
: selectedEntity.position;
|
|
|
|
setEntityPositionDraft(createVec3Draft(selectedEntityPosition));
|
|
|
|
switch (selectedEntity.kind) {
|
|
case "pointLight":
|
|
setPointLightColorDraft(selectedEntity.colorHex);
|
|
setPointLightIntensityDraft(String(selectedEntity.intensity));
|
|
setPointLightDistanceDraft(String(selectedEntity.distance));
|
|
break;
|
|
case "spotLight":
|
|
setSpotLightColorDraft(selectedEntity.colorHex);
|
|
setSpotLightIntensityDraft(String(selectedEntity.intensity));
|
|
setSpotLightDistanceDraft(String(selectedEntity.distance));
|
|
setSpotLightAngleDraft(String(selectedEntity.angleDegrees));
|
|
setSpotLightDirectionDraft(createVec3Draft(selectedEntity.direction));
|
|
break;
|
|
case "cameraRig":
|
|
setCameraRigRigTypeDraft(selectedEntity.rigType);
|
|
setCameraRigPathIdDraft(
|
|
selectedEntity.rigType === "rail" ? selectedEntity.pathId : ""
|
|
);
|
|
setCameraRigRailPlacementModeDraft(
|
|
selectedEntity.rigType === "rail"
|
|
? selectedEntity.railPlacementMode
|
|
: DEFAULT_CAMERA_RIG_RAIL_PLACEMENT_MODE
|
|
);
|
|
setCameraRigTrackStartPointDraft(
|
|
selectedEntity.rigType === "rail" &&
|
|
selectedEntity.railPlacementMode === "mapTargetBetweenPoints"
|
|
? createVec3Draft(selectedEntity.trackStartPoint)
|
|
: getDefaultCameraRigRailMappingDraft(
|
|
selectedEntity.rigType === "rail" ? selectedEntity.pathId : ""
|
|
).trackStartPoint
|
|
);
|
|
setCameraRigTrackEndPointDraft(
|
|
selectedEntity.rigType === "rail" &&
|
|
selectedEntity.railPlacementMode === "mapTargetBetweenPoints"
|
|
? createVec3Draft(selectedEntity.trackEndPoint)
|
|
: getDefaultCameraRigRailMappingDraft(
|
|
selectedEntity.rigType === "rail" ? selectedEntity.pathId : ""
|
|
).trackEndPoint
|
|
);
|
|
setCameraRigRailStartProgressDraft(
|
|
selectedEntity.rigType === "rail" &&
|
|
selectedEntity.railPlacementMode === "mapTargetBetweenPoints"
|
|
? String(selectedEntity.railStartProgress)
|
|
: String(DEFAULT_CAMERA_RIG_RAIL_START_PROGRESS)
|
|
);
|
|
setCameraRigRailEndProgressDraft(
|
|
selectedEntity.rigType === "rail" &&
|
|
selectedEntity.railPlacementMode === "mapTargetBetweenPoints"
|
|
? String(selectedEntity.railEndProgress)
|
|
: String(DEFAULT_CAMERA_RIG_RAIL_END_PROGRESS)
|
|
);
|
|
setCameraRigPriorityDraft(String(selectedEntity.priority));
|
|
setCameraRigDefaultActiveDraft(selectedEntity.defaultActive);
|
|
setCameraRigTargetKindDraft(selectedEntity.target.kind);
|
|
setCameraRigTargetActorIdDraft(
|
|
selectedEntity.target.kind === "actor"
|
|
? selectedEntity.target.actorId
|
|
: ""
|
|
);
|
|
setCameraRigTargetEntityIdDraft(
|
|
selectedEntity.target.kind === "entity"
|
|
? selectedEntity.target.entityId
|
|
: ""
|
|
);
|
|
setCameraRigTargetWorldPointDraft(
|
|
createVec3Draft(
|
|
selectedEntity.target.kind === "worldPoint"
|
|
? selectedEntity.target.point
|
|
: DEFAULT_ENTITY_POSITION
|
|
)
|
|
);
|
|
setCameraRigTargetOffsetDraft(
|
|
createVec3Draft(selectedEntity.targetOffset)
|
|
);
|
|
setCameraRigTransitionModeDraft(selectedEntity.transitionMode);
|
|
setCameraRigTransitionDurationDraft(
|
|
String(selectedEntity.transitionDurationSeconds)
|
|
);
|
|
setCameraRigLookAroundEnabledDraft(selectedEntity.lookAround.enabled);
|
|
setCameraRigLookAroundYawLimitDraft(
|
|
String(selectedEntity.lookAround.yawLimitDegrees)
|
|
);
|
|
setCameraRigLookAroundPitchLimitDraft(
|
|
String(selectedEntity.lookAround.pitchLimitDegrees)
|
|
);
|
|
setCameraRigLookAroundRecenterSpeedDraft(
|
|
String(selectedEntity.lookAround.recenterSpeed)
|
|
);
|
|
break;
|
|
case "playerStart":
|
|
setPlayerStartYawDraft(String(selectedEntity.yawDegrees));
|
|
setPlayerStartNavigationModeDraft(selectedEntity.navigationMode);
|
|
setPlayerStartInteractionReachDraft(
|
|
String(selectedEntity.interactionReachMeters)
|
|
);
|
|
setPlayerStartInteractionAngleDraft(
|
|
String(selectedEntity.interactionAngleDegrees)
|
|
);
|
|
setPlayerStartAllowLookInputTargetSwitchDraft(
|
|
selectedEntity.allowLookInputTargetSwitch
|
|
);
|
|
setPlayerStartTargetButtonCyclesActiveTargetDraft(
|
|
selectedEntity.targetButtonCyclesActiveTarget
|
|
);
|
|
setPlayerStartMovementTemplateDraft(
|
|
clonePlayerStartMovementTemplate(selectedEntity.movementTemplate)
|
|
);
|
|
setPlayerStartMovementTemplateNumberDraft(
|
|
createPlayerStartMovementTemplateNumberDraft(
|
|
selectedEntity.movementTemplate
|
|
)
|
|
);
|
|
setPlayerStartColliderModeDraft(selectedEntity.collider.mode);
|
|
setPlayerStartEyeHeightDraft(String(selectedEntity.collider.eyeHeight));
|
|
setPlayerStartCapsuleRadiusDraft(
|
|
String(selectedEntity.collider.capsuleRadius)
|
|
);
|
|
setPlayerStartCapsuleHeightDraft(
|
|
String(selectedEntity.collider.capsuleHeight)
|
|
);
|
|
setPlayerStartBoxSizeDraft(
|
|
createVec3Draft(selectedEntity.collider.boxSize)
|
|
);
|
|
setPlayerStartInputBindingsDraft(
|
|
clonePlayerStartInputBindings(selectedEntity.inputBindings)
|
|
);
|
|
break;
|
|
case "sceneEntry":
|
|
setSceneEntryYawDraft(String(selectedEntity.yawDegrees));
|
|
break;
|
|
case "npc":
|
|
setNpcActorIdDraft(selectedEntity.actorId);
|
|
setNpcYawDraft(String(selectedEntity.yawDegrees));
|
|
setNpcColliderModeDraft(selectedEntity.collider.mode);
|
|
setNpcEyeHeightDraft(String(selectedEntity.collider.eyeHeight));
|
|
setNpcCapsuleRadiusDraft(String(selectedEntity.collider.capsuleRadius));
|
|
setNpcCapsuleHeightDraft(String(selectedEntity.collider.capsuleHeight));
|
|
setNpcBoxSizeDraft(createVec3Draft(selectedEntity.collider.boxSize));
|
|
setNpcModelAssetIdDraft(selectedEntity.modelAssetId ?? "");
|
|
break;
|
|
case "soundEmitter":
|
|
setSoundEmitterAudioAssetIdDraft(selectedEntity.audioAssetId ?? "");
|
|
setSoundEmitterVolumeDraft(String(selectedEntity.volume));
|
|
setSoundEmitterRefDistanceDraft(String(selectedEntity.refDistance));
|
|
setSoundEmitterMaxDistanceDraft(String(selectedEntity.maxDistance));
|
|
setSoundEmitterAutoplayDraft(selectedEntity.autoplay);
|
|
setSoundEmitterLoopDraft(selectedEntity.loop);
|
|
break;
|
|
case "triggerVolume":
|
|
setTriggerVolumeSizeDraft(createVec3Draft(selectedEntity.size));
|
|
break;
|
|
case "teleportTarget":
|
|
setTeleportTargetYawDraft(String(selectedEntity.yawDegrees));
|
|
break;
|
|
case "interactable":
|
|
setInteractableRadiusDraft(String(selectedEntity.radius));
|
|
setInteractablePromptDraft(selectedEntity.prompt);
|
|
setInteractableEnabledDraft(selectedEntity.interactionEnabled);
|
|
break;
|
|
}
|
|
}, [editorState.projectDocument, selectedEntity]);
|
|
|
|
useEffect(() => {
|
|
if (selectedModelInstance === null) {
|
|
setModelPositionDraft(createVec3Draft(DEFAULT_MODEL_INSTANCE_POSITION));
|
|
setModelRotationDraft(
|
|
createVec3Draft(DEFAULT_MODEL_INSTANCE_ROTATION_DEGREES)
|
|
);
|
|
setModelScaleDraft(createVec3Draft(DEFAULT_MODEL_INSTANCE_SCALE));
|
|
return;
|
|
}
|
|
|
|
setModelPositionDraft(createVec3Draft(selectedModelInstance.position));
|
|
setModelRotationDraft(
|
|
createVec3Draft(selectedModelInstance.rotationDegrees)
|
|
);
|
|
setModelScaleDraft(createVec3Draft(selectedModelInstance.scale));
|
|
}, [selectedModelInstance]);
|
|
|
|
useEffect(() => {
|
|
if (selectedTerrain === null) {
|
|
setTerrainSampleCountXDraft("9");
|
|
setTerrainSampleCountZDraft("9");
|
|
setTerrainCellSizeDraft("1");
|
|
return;
|
|
}
|
|
|
|
setTerrainSampleCountXDraft(String(selectedTerrain.sampleCountX));
|
|
setTerrainSampleCountZDraft(String(selectedTerrain.sampleCountZ));
|
|
setTerrainCellSizeDraft(String(selectedTerrain.cellSize));
|
|
}, [selectedTerrain]);
|
|
|
|
useEffect(() => {
|
|
const projectTime = editorState.projectDocument.time;
|
|
|
|
setProjectTimeStartDayNumberDraft(String(projectTime.startDayNumber));
|
|
setProjectTimeStartTimeOfDayDraft(String(projectTime.startTimeOfDayHours));
|
|
setProjectTimeDayLengthMinutesDraft(String(projectTime.dayLengthMinutes));
|
|
setProjectTimeSunriseTimeOfDayDraft(
|
|
String(projectTime.sunriseTimeOfDayHours)
|
|
);
|
|
setProjectTimeSunsetTimeOfDayDraft(
|
|
String(projectTime.sunsetTimeOfDayHours)
|
|
);
|
|
setProjectTimeDawnDurationHoursDraft(String(projectTime.dawnDurationHours));
|
|
setProjectTimeDuskDurationHoursDraft(String(projectTime.duskDurationHours));
|
|
}, [editorState.projectDocument.time]);
|
|
|
|
useEffect(() => {
|
|
return editorSimulationController.subscribeUiSnapshot(
|
|
setEditorSimulationSnapshot
|
|
);
|
|
}, [editorSimulationController]);
|
|
|
|
useEffect(() => {
|
|
editorSimulationController.updateInputs({
|
|
document: editorState.document,
|
|
loadedModelAssets
|
|
});
|
|
}, [editorSimulationController, editorState.document, loadedModelAssets]);
|
|
|
|
useEffect(() => {
|
|
if (editorState.toolMode === "play") {
|
|
editorSimulationController.pause();
|
|
}
|
|
}, [editorSimulationController, editorState.toolMode]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
editorSimulationController.dispose();
|
|
};
|
|
}, [editorSimulationController]);
|
|
|
|
useEffect(() => {
|
|
if (selectedScheduleRoutineId === null) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
editorState.projectDocument.scheduler.routines[
|
|
selectedScheduleRoutineId
|
|
] !== undefined
|
|
) {
|
|
return;
|
|
}
|
|
|
|
setSelectedScheduleRoutineId(null);
|
|
}, [editorState.projectDocument.scheduler, selectedScheduleRoutineId]);
|
|
|
|
useEffect(() => {
|
|
const worldTimeOfDay = editorState.document.world.timeOfDay;
|
|
|
|
setWorldDawnAmbientIntensityFactorDraft(
|
|
String(worldTimeOfDay.dawn.ambientIntensityFactor)
|
|
);
|
|
setWorldDawnLightIntensityFactorDraft(
|
|
String(worldTimeOfDay.dawn.lightIntensityFactor)
|
|
);
|
|
setWorldDawnBackgroundEnvironmentIntensityDraft(
|
|
worldTimeOfDay.dawn.background.mode === "image"
|
|
? String(worldTimeOfDay.dawn.background.environmentIntensity)
|
|
: String(DEFAULT_TIME_PHASE_IMAGE_ENVIRONMENT_INTENSITY)
|
|
);
|
|
setWorldDuskAmbientIntensityFactorDraft(
|
|
String(worldTimeOfDay.dusk.ambientIntensityFactor)
|
|
);
|
|
setWorldDuskLightIntensityFactorDraft(
|
|
String(worldTimeOfDay.dusk.lightIntensityFactor)
|
|
);
|
|
setWorldDuskBackgroundEnvironmentIntensityDraft(
|
|
worldTimeOfDay.dusk.background.mode === "image"
|
|
? String(worldTimeOfDay.dusk.background.environmentIntensity)
|
|
: String(DEFAULT_TIME_PHASE_IMAGE_ENVIRONMENT_INTENSITY)
|
|
);
|
|
setWorldNightAmbientIntensityFactorDraft(
|
|
String(worldTimeOfDay.night.ambientIntensityFactor)
|
|
);
|
|
setWorldNightLightIntensityFactorDraft(
|
|
String(worldTimeOfDay.night.lightIntensityFactor)
|
|
);
|
|
setWorldNightBackgroundEnvironmentIntensityDraft(
|
|
worldTimeOfDay.night.background.mode === "image"
|
|
? String(worldTimeOfDay.night.background.environmentIntensity)
|
|
: String(DEFAULT_NIGHT_IMAGE_ENVIRONMENT_INTENSITY)
|
|
);
|
|
}, [editorState.document.world.timeOfDay]);
|
|
|
|
useEffect(() => {
|
|
setAmbientLightIntensityDraft(
|
|
String(editorState.document.world.ambientLight.intensity)
|
|
);
|
|
}, [editorState.document.world.ambientLight.intensity]);
|
|
|
|
useEffect(() => {
|
|
if (editorState.document.world.background.mode === "image") {
|
|
setBackgroundEnvironmentIntensityDraft(
|
|
String(editorState.document.world.background.environmentIntensity)
|
|
);
|
|
}
|
|
}, [editorState.document.world.background]);
|
|
|
|
useEffect(() => {
|
|
setSunLightIntensityDraft(
|
|
String(editorState.document.world.sunLight.intensity)
|
|
);
|
|
}, [editorState.document.world.sunLight.intensity]);
|
|
|
|
useEffect(() => {
|
|
const advancedRendering = editorState.document.world.advancedRendering;
|
|
setAdvancedRenderingShadowBiasDraft(String(advancedRendering.shadows.bias));
|
|
setAdvancedRenderingAmbientOcclusionIntensityDraft(
|
|
String(advancedRendering.ambientOcclusion.intensity)
|
|
);
|
|
setAdvancedRenderingAmbientOcclusionRadiusDraft(
|
|
String(advancedRendering.ambientOcclusion.radius)
|
|
);
|
|
setAdvancedRenderingAmbientOcclusionSamplesDraft(
|
|
String(advancedRendering.ambientOcclusion.samples)
|
|
);
|
|
setAdvancedRenderingBloomIntensityDraft(
|
|
String(advancedRendering.bloom.intensity)
|
|
);
|
|
setAdvancedRenderingBloomThresholdDraft(
|
|
String(advancedRendering.bloom.threshold)
|
|
);
|
|
setAdvancedRenderingBloomRadiusDraft(
|
|
String(advancedRendering.bloom.radius)
|
|
);
|
|
setAdvancedRenderingToneMappingExposureDraft(
|
|
String(advancedRendering.toneMapping.exposure)
|
|
);
|
|
setAdvancedRenderingDepthOfFieldFocusDistanceDraft(
|
|
String(advancedRendering.depthOfField.focusDistance)
|
|
);
|
|
setAdvancedRenderingDepthOfFieldFocalLengthDraft(
|
|
String(advancedRendering.depthOfField.focalLength)
|
|
);
|
|
setAdvancedRenderingDepthOfFieldBokehScaleDraft(
|
|
String(advancedRendering.depthOfField.bokehScale)
|
|
);
|
|
setAdvancedRenderingWhiteboxBevelEdgeWidthDraft(
|
|
String(advancedRendering.whiteboxBevel.edgeWidth)
|
|
);
|
|
setAdvancedRenderingWhiteboxBevelNormalStrengthDraft(
|
|
String(advancedRendering.whiteboxBevel.normalStrength)
|
|
);
|
|
}, [editorState.document.world.advancedRendering]);
|
|
|
|
useEffect(() => {
|
|
loadedImageAssetsRef.current = loadedImageAssets;
|
|
}, [loadedImageAssets]);
|
|
|
|
useEffect(() => {
|
|
loadedModelAssetsRef.current = loadedModelAssets;
|
|
}, [loadedModelAssets]);
|
|
|
|
useEffect(() => {
|
|
loadedAudioAssetsRef.current = loadedAudioAssets;
|
|
}, [loadedAudioAssets]);
|
|
|
|
useEffect(() => {
|
|
viewportQuadSplitRef.current = editorState.viewportQuadSplit;
|
|
}, [editorState.viewportQuadSplit]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
void (async () => {
|
|
const access = await getBrowserProjectAssetStorageAccess();
|
|
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
|
|
setProjectAssetStorage(access.storage);
|
|
setAssetStatusMessage(access.diagnostic);
|
|
setProjectAssetStorageReady(true);
|
|
})().catch((error) => {
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
|
|
setProjectAssetStorage(null);
|
|
setProjectAssetStorageReady(true);
|
|
setAssetStatusMessage(getErrorMessage(error));
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
autosaveControllerRef.current = new EditorAutosaveController({
|
|
saveDraft: () => store.saveDraft(),
|
|
onComplete: (result) => {
|
|
if (result.status === "error") {
|
|
if (lastAutosaveErrorRef.current !== result.message) {
|
|
lastAutosaveErrorRef.current = result.message;
|
|
setStatusMessage(result.message);
|
|
}
|
|
return;
|
|
}
|
|
|
|
lastAutosaveErrorRef.current = null;
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
autosaveControllerRef.current?.dispose();
|
|
autosaveControllerRef.current = null;
|
|
};
|
|
}, [store]);
|
|
|
|
useEffect(() => {
|
|
if (!editorState.storageAvailable) {
|
|
return;
|
|
}
|
|
|
|
autosaveControllerRef.current?.schedule();
|
|
}, [
|
|
editorState.activeViewportPanelId,
|
|
editorState.document,
|
|
editorState.storageAvailable,
|
|
editorState.viewportLayoutMode,
|
|
editorState.viewportPanels,
|
|
editorState.viewportQuadSplit
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (!editorState.storageAvailable) {
|
|
return;
|
|
}
|
|
|
|
const flushAutosave = () => {
|
|
autosaveControllerRef.current?.flush();
|
|
};
|
|
const handleVisibilityChange = () => {
|
|
if (document.visibilityState === "hidden") {
|
|
flushAutosave();
|
|
}
|
|
};
|
|
|
|
window.addEventListener("beforeunload", flushAutosave);
|
|
window.addEventListener("pagehide", flushAutosave);
|
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
|
|
return () => {
|
|
window.removeEventListener("beforeunload", flushAutosave);
|
|
window.removeEventListener("pagehide", flushAutosave);
|
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
};
|
|
}, [editorState.storageAvailable]);
|
|
|
|
useEffect(() => {
|
|
if (!projectAssetStorageReady) {
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
const currentAssets = editorState.document.assets;
|
|
const previousAssets = previousProjectAssetsRef.current;
|
|
const previousLoadedModelAssets = loadedModelAssetsRef.current;
|
|
const previousLoadedImageAssets = loadedImageAssetsRef.current;
|
|
const previousLoadedAudioAssets = loadedAudioAssetsRef.current;
|
|
const previousLoadedModelAssetIds = new Set(
|
|
Object.keys(previousLoadedModelAssets)
|
|
);
|
|
const previousLoadedImageAssetIds = new Set(
|
|
Object.keys(previousLoadedImageAssets)
|
|
);
|
|
const previousLoadedAudioAssetIds = new Set(
|
|
Object.keys(previousLoadedAudioAssets)
|
|
);
|
|
const nextLoadedModelAssets: Record<string, LoadedModelAsset> = {};
|
|
const nextLoadedImageAssets: Record<string, LoadedImageAsset> = {};
|
|
const nextLoadedAudioAssets: Record<string, LoadedAudioAsset> = {};
|
|
const syncErrorMessages: string[] = [];
|
|
|
|
const restoreDeletedStoredAsset = async (
|
|
storage: ProjectAssetStorage,
|
|
asset: ProjectAssetRecord
|
|
) => {
|
|
const deletedRecord =
|
|
deletedProjectAssetStorageRecordsRef.current[asset.storageKey];
|
|
|
|
if (deletedRecord === undefined) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
await storage.putAsset(
|
|
asset.storageKey,
|
|
cloneProjectAssetStorageRecord(deletedRecord)
|
|
);
|
|
delete deletedProjectAssetStorageRecordsRef.current[asset.storageKey];
|
|
return true;
|
|
} catch (error) {
|
|
syncErrorMessages.push(
|
|
`Stored data for ${asset.sourceName} could not be restored: ${getErrorMessage(error)}`
|
|
);
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const syncModelAsset = async (
|
|
storage: ProjectAssetStorage,
|
|
asset: ModelAssetRecord
|
|
) => {
|
|
try {
|
|
return await loadModelAssetFromStorage(storage, asset);
|
|
} catch (error) {
|
|
if (await restoreDeletedStoredAsset(storage, asset)) {
|
|
return loadModelAssetFromStorage(storage, asset);
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
const syncImageAsset = async (
|
|
storage: ProjectAssetStorage | null,
|
|
asset: ImageAssetRecord
|
|
) => {
|
|
try {
|
|
return await loadImageAssetFromStorage(storage, asset);
|
|
} catch (error) {
|
|
if (
|
|
storage !== null &&
|
|
(await restoreDeletedStoredAsset(storage, asset))
|
|
) {
|
|
return loadImageAssetFromStorage(storage, asset);
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
const syncAudioAsset = async (
|
|
storage: ProjectAssetStorage,
|
|
asset: AudioAssetRecord
|
|
) => {
|
|
try {
|
|
return await loadAudioAssetFromStorage(storage, asset);
|
|
} catch (error) {
|
|
if (await restoreDeletedStoredAsset(storage, asset)) {
|
|
return loadAudioAssetFromStorage(storage, asset);
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
const syncAssets = async () => {
|
|
const storage = projectAssetStorage;
|
|
|
|
for (const asset of Object.values(currentAssets)) {
|
|
if (isModelAsset(asset)) {
|
|
if (storage === null) {
|
|
continue;
|
|
}
|
|
|
|
previousLoadedModelAssetIds.delete(asset.id);
|
|
|
|
const cachedLoadedAsset = previousLoadedModelAssets[asset.id];
|
|
|
|
if (
|
|
cachedLoadedAsset !== undefined &&
|
|
cachedLoadedAsset.storageKey === asset.storageKey
|
|
) {
|
|
nextLoadedModelAssets[asset.id] = cachedLoadedAsset;
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
nextLoadedModelAssets[asset.id] = await syncModelAsset(
|
|
storage,
|
|
asset
|
|
);
|
|
} catch (error) {
|
|
syncErrorMessages.push(
|
|
`Model asset ${asset.sourceName} could not be restored: ${getErrorMessage(error)}`
|
|
);
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
if (isImageAsset(asset)) {
|
|
previousLoadedImageAssetIds.delete(asset.id);
|
|
|
|
const cachedLoadedAsset = previousLoadedImageAssets[asset.id];
|
|
|
|
if (
|
|
cachedLoadedAsset !== undefined &&
|
|
cachedLoadedAsset.storageKey === asset.storageKey
|
|
) {
|
|
nextLoadedImageAssets[asset.id] = cachedLoadedAsset;
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
nextLoadedImageAssets[asset.id] = await syncImageAsset(
|
|
storage,
|
|
asset
|
|
);
|
|
} catch (error) {
|
|
syncErrorMessages.push(
|
|
`Image asset ${asset.sourceName} could not be restored: ${getErrorMessage(error)}`
|
|
);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (isAudioAsset(asset)) {
|
|
if (storage === null) {
|
|
continue;
|
|
}
|
|
|
|
previousLoadedAudioAssetIds.delete(asset.id);
|
|
|
|
const cachedLoadedAsset = previousLoadedAudioAssets[asset.id];
|
|
|
|
if (
|
|
cachedLoadedAsset !== undefined &&
|
|
cachedLoadedAsset.storageKey === asset.storageKey
|
|
) {
|
|
nextLoadedAudioAssets[asset.id] = cachedLoadedAsset;
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
nextLoadedAudioAssets[asset.id] = await syncAudioAsset(
|
|
storage,
|
|
asset
|
|
);
|
|
} catch (error) {
|
|
syncErrorMessages.push(
|
|
`Audio asset ${asset.sourceName} could not be restored: ${getErrorMessage(error)}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (cancelled) {
|
|
for (const loadedAsset of Object.values(nextLoadedModelAssets)) {
|
|
if (previousLoadedModelAssets[loadedAsset.assetId] !== loadedAsset) {
|
|
disposeModelTemplate(loadedAsset.template);
|
|
}
|
|
}
|
|
|
|
for (const loadedAsset of Object.values(nextLoadedImageAssets)) {
|
|
if (previousLoadedImageAssets[loadedAsset.assetId] !== loadedAsset) {
|
|
disposeLoadedImageAsset(loadedAsset);
|
|
}
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
const removedAssets = Object.values(previousAssets).filter(
|
|
(asset) => currentAssets[asset.id] === undefined
|
|
);
|
|
|
|
if (storage === null) {
|
|
for (const assetId of previousLoadedModelAssetIds) {
|
|
const removedAsset = previousLoadedModelAssets[assetId];
|
|
|
|
if (removedAsset !== undefined) {
|
|
disposeModelTemplate(removedAsset.template);
|
|
}
|
|
}
|
|
|
|
for (const assetId of previousLoadedImageAssetIds) {
|
|
const removedAsset = previousLoadedImageAssets[assetId];
|
|
|
|
if (removedAsset !== undefined) {
|
|
disposeLoadedImageAsset(removedAsset);
|
|
}
|
|
}
|
|
|
|
loadedModelAssetsRef.current = nextLoadedModelAssets;
|
|
loadedImageAssetsRef.current = nextLoadedImageAssets;
|
|
loadedAudioAssetsRef.current = nextLoadedAudioAssets;
|
|
previousProjectAssetsRef.current = currentAssets;
|
|
setLoadedModelAssets(nextLoadedModelAssets);
|
|
setLoadedImageAssets(nextLoadedImageAssets);
|
|
setLoadedAudioAssets(nextLoadedAudioAssets);
|
|
setAssetStatusMessage(
|
|
syncErrorMessages.length === 0 ? null : syncErrorMessages.join(" | ")
|
|
);
|
|
return;
|
|
}
|
|
|
|
for (const removedAsset of removedAssets) {
|
|
try {
|
|
const storedAsset = await storage.getAsset(removedAsset.storageKey);
|
|
|
|
if (storedAsset !== null) {
|
|
deletedProjectAssetStorageRecordsRef.current[
|
|
removedAsset.storageKey
|
|
] = cloneProjectAssetStorageRecord(storedAsset);
|
|
await storage.deleteAsset(removedAsset.storageKey);
|
|
}
|
|
} catch (error) {
|
|
syncErrorMessages.push(
|
|
`Stored data for ${removedAsset.sourceName} could not be deleted: ${getErrorMessage(error)}`
|
|
);
|
|
}
|
|
}
|
|
|
|
for (const assetId of previousLoadedModelAssetIds) {
|
|
const removedAsset = previousLoadedModelAssets[assetId];
|
|
|
|
if (removedAsset !== undefined) {
|
|
disposeModelTemplate(removedAsset.template);
|
|
}
|
|
}
|
|
|
|
for (const assetId of previousLoadedImageAssetIds) {
|
|
const removedAsset = previousLoadedImageAssets[assetId];
|
|
|
|
if (removedAsset !== undefined) {
|
|
disposeLoadedImageAsset(removedAsset);
|
|
}
|
|
}
|
|
|
|
loadedModelAssetsRef.current = nextLoadedModelAssets;
|
|
loadedImageAssetsRef.current = nextLoadedImageAssets;
|
|
loadedAudioAssetsRef.current = nextLoadedAudioAssets;
|
|
previousProjectAssetsRef.current = currentAssets;
|
|
setLoadedModelAssets(nextLoadedModelAssets);
|
|
setLoadedImageAssets(nextLoadedImageAssets);
|
|
setLoadedAudioAssets(nextLoadedAudioAssets);
|
|
setAssetStatusMessage(
|
|
syncErrorMessages.length === 0 ? null : syncErrorMessages.join(" | ")
|
|
);
|
|
};
|
|
|
|
void syncAssets();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [
|
|
editorState.document.assets,
|
|
projectAssetStorage,
|
|
projectAssetStorageReady
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (editorState.toolMode === "play") {
|
|
return;
|
|
}
|
|
|
|
const handleWindowPointerMove = (event: globalThis.PointerEvent) => {
|
|
lastPointerPositionRef.current = {
|
|
x: event.clientX,
|
|
y: event.clientY
|
|
};
|
|
|
|
const hoveredViewportPanelElement =
|
|
event.target instanceof Element
|
|
? event.target.closest<HTMLElement>("[data-viewport-panel-id]")
|
|
: null;
|
|
const hoveredPanelId =
|
|
hoveredViewportPanelElement?.dataset.viewportPanelId;
|
|
|
|
setHoveredViewportPanelId(
|
|
hoveredPanelId === "topLeft" ||
|
|
hoveredPanelId === "topRight" ||
|
|
hoveredPanelId === "bottomLeft" ||
|
|
hoveredPanelId === "bottomRight"
|
|
? hoveredPanelId
|
|
: null
|
|
);
|
|
};
|
|
|
|
const handleWindowKeyDown = (event: globalThis.KeyboardEvent) => {
|
|
if (isTextEntryTarget(event.target)) {
|
|
return;
|
|
}
|
|
|
|
const hasPrimaryModifier =
|
|
(event.metaKey || event.ctrlKey) && !event.altKey;
|
|
|
|
if (hasPrimaryModifier && event.code === "KeyR" && !event.shiftKey) {
|
|
event.preventDefault();
|
|
handleEnterPlayMode();
|
|
return;
|
|
}
|
|
|
|
if (hasPrimaryModifier && event.code === "KeyS") {
|
|
event.preventDefault();
|
|
void handleSaveProject();
|
|
return;
|
|
}
|
|
|
|
if (hasPrimaryModifier && event.code === "KeyZ") {
|
|
event.preventDefault();
|
|
|
|
if (event.shiftKey) {
|
|
handleRedo();
|
|
} else {
|
|
handleUndo();
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (hasPrimaryModifier && event.code === "KeyY") {
|
|
event.preventDefault();
|
|
handleRedo();
|
|
|
|
return;
|
|
}
|
|
|
|
if (event.key === "Escape" && addMenuPosition !== null) {
|
|
event.preventDefault();
|
|
setAddMenuPosition(null);
|
|
return;
|
|
}
|
|
|
|
if (transformSession.kind === "active") {
|
|
if (event.key === "Escape") {
|
|
event.preventDefault();
|
|
cancelTransformSession();
|
|
return;
|
|
}
|
|
|
|
if (event.key === "Enter") {
|
|
event.preventDefault();
|
|
const latestTransformSession =
|
|
latestActiveTransformSessionRef.current;
|
|
commitTransformSession(
|
|
latestTransformSession?.id === transformSession.id
|
|
? latestTransformSession
|
|
: transformSession
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (!event.altKey && !event.ctrlKey && !event.metaKey) {
|
|
if (event.code === "KeyX") {
|
|
event.preventDefault();
|
|
applyTransformAxisConstraint("x");
|
|
return;
|
|
}
|
|
|
|
if (event.code === "KeyY") {
|
|
event.preventDefault();
|
|
applyTransformAxisConstraint("y");
|
|
return;
|
|
}
|
|
|
|
if (event.code === "KeyZ") {
|
|
event.preventDefault();
|
|
applyTransformAxisConstraint("z");
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (event.key === "Escape" && editorState.toolMode === "create") {
|
|
event.preventDefault();
|
|
store.setToolMode("select");
|
|
setStatusMessage("Cancelled the current creation preview.");
|
|
return;
|
|
}
|
|
|
|
if (event.shiftKey && event.code === "KeyA") {
|
|
event.preventDefault();
|
|
setAddMenuPosition({
|
|
x: lastPointerPositionRef.current.x,
|
|
y: lastPointerPositionRef.current.y
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!event.altKey && !event.ctrlKey && !event.metaKey) {
|
|
let transformOperation: TransformOperation | null = null;
|
|
|
|
if (event.code === "KeyG") {
|
|
transformOperation = "translate";
|
|
} else if (event.code === "KeyR") {
|
|
transformOperation = "rotate";
|
|
} else if (event.code === "KeyS") {
|
|
transformOperation = "scale";
|
|
}
|
|
|
|
if (transformOperation !== null) {
|
|
event.preventDefault();
|
|
beginTransformOperation(transformOperation, "keyboard");
|
|
return;
|
|
}
|
|
|
|
const whiteboxSelectionModeShortcut =
|
|
resolveWhiteboxSelectionModeShortcut(event);
|
|
|
|
if (whiteboxSelectionModeShortcut !== null) {
|
|
event.preventDefault();
|
|
handleWhiteboxSelectionModeChange(whiteboxSelectionModeShortcut);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const isDeletionKey = event.key === "Delete" || event.key === "Backspace";
|
|
const isDeleteShortcut =
|
|
!event.altKey &&
|
|
!event.ctrlKey &&
|
|
!event.metaKey &&
|
|
(event.code === "KeyX" || isDeletionKey);
|
|
const isDuplicateShortcut =
|
|
event.shiftKey &&
|
|
!event.altKey &&
|
|
!event.ctrlKey &&
|
|
!event.metaKey &&
|
|
event.code === "KeyD";
|
|
|
|
if (addMenuPosition !== null) {
|
|
if (isDeletionKey) {
|
|
event.preventDefault();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (isDuplicateShortcut) {
|
|
const duplicated = handleDuplicateSelection();
|
|
|
|
if (duplicated) {
|
|
event.preventDefault();
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
const isAppendPathPointShortcut =
|
|
event.shiftKey &&
|
|
!event.altKey &&
|
|
!event.ctrlKey &&
|
|
!event.metaKey &&
|
|
event.code === "KeyW";
|
|
|
|
if (isAppendPathPointShortcut) {
|
|
if (editorState.toolMode === "select" && selectedPath !== null) {
|
|
event.preventDefault();
|
|
handleAddPathPoint();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (isDeleteShortcut) {
|
|
if (editorState.toolMode !== "create") {
|
|
const deleted = handleDeleteSelectedSceneItem();
|
|
|
|
if (deleted || isDeletionKey) {
|
|
event.preventDefault();
|
|
}
|
|
} else if (isDeletionKey) {
|
|
event.preventDefault();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (
|
|
event.code !== "NumpadComma" &&
|
|
!(
|
|
event.key === "," &&
|
|
event.location === globalThis.KeyboardEvent.DOM_KEY_LOCATION_NUMPAD
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
|
|
if (
|
|
editorState.selection.kind === "none" &&
|
|
brushList.length === 0 &&
|
|
entityList.length === 0
|
|
) {
|
|
setStatusMessage("Nothing authored yet to frame in the viewport.");
|
|
return;
|
|
}
|
|
|
|
setFocusRequest((current) => ({
|
|
id: current.id + 1,
|
|
panelId: activePanelId,
|
|
selection: editorState.selection
|
|
}));
|
|
setStatusMessage(
|
|
editorState.selection.kind === "none"
|
|
? "Framed the authored scene in the viewport."
|
|
: "Framed the current selection."
|
|
);
|
|
};
|
|
|
|
document.addEventListener("pointermove", handleWindowPointerMove);
|
|
window.addEventListener("pointermove", handleWindowPointerMove);
|
|
window.addEventListener("keydown", handleWindowKeyDown);
|
|
|
|
return () => {
|
|
document.removeEventListener("pointermove", handleWindowPointerMove);
|
|
window.removeEventListener("pointermove", handleWindowPointerMove);
|
|
window.removeEventListener("keydown", handleWindowKeyDown);
|
|
};
|
|
}, [
|
|
activePanelId,
|
|
addMenuPosition,
|
|
brushList.length,
|
|
editorState.document,
|
|
editorState.selection,
|
|
editorState.toolMode,
|
|
entityList.length,
|
|
hoveredViewportPanelId,
|
|
layoutMode,
|
|
projectAssetStorage,
|
|
projectAssetStorageReady,
|
|
transformSession
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (layoutMode === "quad" || viewportQuadResizeMode === null) {
|
|
return;
|
|
}
|
|
|
|
setViewportQuadResizeMode(null);
|
|
}, [layoutMode, viewportQuadResizeMode]);
|
|
|
|
useEffect(() => {
|
|
if (layoutMode !== "quad" || viewportQuadResizeMode === null) {
|
|
return;
|
|
}
|
|
|
|
const previousCursor = document.body.style.cursor;
|
|
const previousUserSelect = document.body.style.userSelect;
|
|
document.body.style.cursor = getViewportQuadResizeCursor(
|
|
viewportQuadResizeMode
|
|
);
|
|
document.body.style.userSelect = "none";
|
|
|
|
const handlePointerMove = (event: globalThis.PointerEvent) => {
|
|
const viewportPanels = viewportPanelsRef.current;
|
|
|
|
if (viewportPanels === null) {
|
|
return;
|
|
}
|
|
|
|
const rect = viewportPanels.getBoundingClientRect();
|
|
|
|
if (rect.width <= 0 || rect.height <= 0) {
|
|
return;
|
|
}
|
|
|
|
const nextViewportQuadSplit = {
|
|
...viewportQuadSplitRef.current
|
|
};
|
|
|
|
if (viewportQuadResizeMode !== "horizontal") {
|
|
nextViewportQuadSplit.x = clampViewportQuadSplitValue(
|
|
(event.clientX - rect.left) / rect.width
|
|
);
|
|
}
|
|
|
|
if (viewportQuadResizeMode !== "vertical") {
|
|
nextViewportQuadSplit.y = clampViewportQuadSplitValue(
|
|
(event.clientY - rect.top) / rect.height
|
|
);
|
|
}
|
|
|
|
store.setViewportQuadSplit(nextViewportQuadSplit);
|
|
};
|
|
|
|
const stopViewportResize = () => {
|
|
setViewportQuadResizeMode(null);
|
|
};
|
|
|
|
window.addEventListener("pointermove", handlePointerMove);
|
|
window.addEventListener("pointerup", stopViewportResize);
|
|
window.addEventListener("pointercancel", stopViewportResize);
|
|
|
|
return () => {
|
|
document.body.style.cursor = previousCursor;
|
|
document.body.style.userSelect = previousUserSelect;
|
|
window.removeEventListener("pointermove", handlePointerMove);
|
|
window.removeEventListener("pointerup", stopViewportResize);
|
|
window.removeEventListener("pointercancel", stopViewportResize);
|
|
};
|
|
}, [layoutMode, store, viewportQuadResizeMode]);
|
|
|
|
useEffect(() => {
|
|
if (editorState.toolMode !== "play") {
|
|
return;
|
|
}
|
|
|
|
const handleWindowKeyDown = (event: globalThis.KeyboardEvent) => {
|
|
if (isTextEntryTarget(event.target)) {
|
|
return;
|
|
}
|
|
|
|
if (event.key !== "Escape") {
|
|
return;
|
|
}
|
|
|
|
const pointerCaptured =
|
|
activeNavigationMode === "firstPerson" &&
|
|
firstPersonTelemetry?.pointerLocked === true;
|
|
|
|
if (pointerCaptured) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
handleExitPlayMode();
|
|
};
|
|
|
|
window.addEventListener("keydown", handleWindowKeyDown);
|
|
|
|
return () => {
|
|
window.removeEventListener("keydown", handleWindowKeyDown);
|
|
};
|
|
}, [activeNavigationMode, editorState.toolMode, firstPersonTelemetry]);
|
|
|
|
const applyProjectName = () => {
|
|
const normalizedName = projectNameDraft.trim() || DEFAULT_PROJECT_NAME;
|
|
|
|
if (normalizedName === editorState.projectDocument.name) {
|
|
return;
|
|
}
|
|
|
|
store.executeCommand(createSetProjectNameCommand(normalizedName));
|
|
setStatusMessage(`Project renamed to ${normalizedName}.`);
|
|
};
|
|
|
|
const applyProjectTimeSettings = (
|
|
nextTime: ProjectTimeSettings,
|
|
label: string,
|
|
successMessage: string,
|
|
options: {
|
|
resetEditorSimulation?: boolean;
|
|
} = {}
|
|
) => {
|
|
if (
|
|
areProjectTimeSettingsEqual(editorState.projectDocument.time, nextTime)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
store.executeCommand(
|
|
createSetProjectTimeSettingsCommand({
|
|
label,
|
|
time: nextTime
|
|
})
|
|
);
|
|
if (options.resetEditorSimulation === true && !editorSimulationPlaying) {
|
|
editorSimulationController.reset();
|
|
}
|
|
setStatusMessage(successMessage);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const applyProjectScheduler = (
|
|
nextScheduler: typeof editorState.projectDocument.scheduler,
|
|
label: string,
|
|
successMessage: string
|
|
) => {
|
|
if (
|
|
areProjectSchedulersEqual(
|
|
editorState.projectDocument.scheduler,
|
|
nextScheduler
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
store.executeCommand(
|
|
createSetProjectSchedulerCommand({
|
|
label,
|
|
scheduler: nextScheduler
|
|
})
|
|
);
|
|
setStatusMessage(successMessage);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const applyProjectSequences = (
|
|
nextSequences: ProjectSequenceLibrary,
|
|
label: string,
|
|
successMessage: string
|
|
) => {
|
|
if (
|
|
areProjectSequenceLibrariesEqual(
|
|
editorState.projectDocument.sequences,
|
|
nextSequences
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
store.executeCommand(
|
|
createSetProjectSequencesCommand({
|
|
label,
|
|
sequences: nextSequences
|
|
})
|
|
);
|
|
setStatusMessage(successMessage);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const applyProjectSequencerState = (
|
|
nextScheduler: typeof editorState.projectDocument.scheduler,
|
|
nextSequences: ProjectSequenceLibrary,
|
|
label: string,
|
|
successMessage: string
|
|
) => {
|
|
if (
|
|
areProjectSchedulersEqual(
|
|
editorState.projectDocument.scheduler,
|
|
nextScheduler
|
|
) &&
|
|
areProjectSequenceLibrariesEqual(
|
|
editorState.projectDocument.sequences,
|
|
nextSequences
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
store.executeCommand(
|
|
createSetProjectSequencerCommand({
|
|
label,
|
|
scheduler: nextScheduler,
|
|
sequences: nextSequences
|
|
})
|
|
);
|
|
setStatusMessage(successMessage);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const updateProjectSequences = (
|
|
label: string,
|
|
successMessage: string,
|
|
mutate: (sequences: ProjectSequenceLibrary) => void
|
|
) => {
|
|
try {
|
|
const nextSequences = cloneProjectSequenceLibrary(
|
|
editorState.projectDocument.sequences
|
|
);
|
|
mutate(nextSequences);
|
|
applyProjectSequences(nextSequences, label, successMessage);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const updateProjectSequencerState = (
|
|
label: string,
|
|
successMessage: string,
|
|
mutate: (
|
|
scheduler: typeof editorState.projectDocument.scheduler,
|
|
sequences: ProjectSequenceLibrary
|
|
) => void
|
|
) => {
|
|
try {
|
|
const nextScheduler = cloneProjectScheduler(
|
|
editorState.projectDocument.scheduler
|
|
);
|
|
const nextSequences = cloneProjectSequenceLibrary(
|
|
editorState.projectDocument.sequences
|
|
);
|
|
mutate(nextScheduler, nextSequences);
|
|
applyProjectSequencerState(
|
|
nextScheduler,
|
|
nextSequences,
|
|
label,
|
|
successMessage
|
|
);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const updateProjectTimeSettings = (
|
|
label: string,
|
|
successMessage: string,
|
|
mutate: (time: ProjectTimeSettings) => void,
|
|
options: {
|
|
resetEditorSimulation?: boolean;
|
|
} = {}
|
|
) => {
|
|
try {
|
|
const nextTime = cloneProjectTimeSettings(
|
|
editorState.projectDocument.time
|
|
);
|
|
mutate(nextTime);
|
|
assertProjectTimeSettingsAreOrdered(nextTime);
|
|
applyProjectTimeSettings(nextTime, label, successMessage, options);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const applyProjectTimeStartDayNumber = () => {
|
|
updateProjectTimeSettings(
|
|
"Set project start day",
|
|
`Runner sessions will begin on day ${projectTimeStartDayNumberDraft}.`,
|
|
(time) => {
|
|
time.startDayNumber = readPositiveIntegerDraft(
|
|
projectTimeStartDayNumberDraft,
|
|
"Project start day"
|
|
);
|
|
},
|
|
{
|
|
resetEditorSimulation: true
|
|
}
|
|
);
|
|
};
|
|
|
|
const applyProjectTimeStartTimeOfDay = () => {
|
|
updateProjectTimeSettings(
|
|
"Set project start time",
|
|
`Project time will start at ${formatTimeOfDayHours(Number(projectTimeStartTimeOfDayDraft))}.`,
|
|
(time) => {
|
|
time.startTimeOfDayHours = normalizeTimeOfDayHours(
|
|
readFiniteNumberDraft(
|
|
projectTimeStartTimeOfDayDraft,
|
|
"Project start time"
|
|
)
|
|
);
|
|
},
|
|
{
|
|
resetEditorSimulation: true
|
|
}
|
|
);
|
|
};
|
|
|
|
const applyProjectTimeDayLengthMinutes = () => {
|
|
updateProjectTimeSettings(
|
|
"Set project day length",
|
|
`Project time will advance one full day every ${projectTimeDayLengthMinutesDraft} real minutes.`,
|
|
(time) => {
|
|
time.dayLengthMinutes = readPositiveNumberDraft(
|
|
projectTimeDayLengthMinutesDraft,
|
|
"Project day duration"
|
|
);
|
|
}
|
|
);
|
|
};
|
|
|
|
const applyProjectTimeSunriseTimeOfDay = () => {
|
|
updateProjectTimeSettings(
|
|
"Set project sunrise",
|
|
`Project sunrise will occur at ${formatTimeOfDayHours(Number(projectTimeSunriseTimeOfDayDraft))}.`,
|
|
(time) => {
|
|
time.sunriseTimeOfDayHours = normalizeTimeOfDayHours(
|
|
readFiniteNumberDraft(
|
|
projectTimeSunriseTimeOfDayDraft,
|
|
"Project sunrise"
|
|
)
|
|
);
|
|
}
|
|
);
|
|
};
|
|
|
|
const applyProjectTimeSunsetTimeOfDay = () => {
|
|
updateProjectTimeSettings(
|
|
"Set project sunset",
|
|
`Project sunset will occur at ${formatTimeOfDayHours(Number(projectTimeSunsetTimeOfDayDraft))}.`,
|
|
(time) => {
|
|
time.sunsetTimeOfDayHours = normalizeTimeOfDayHours(
|
|
readFiniteNumberDraft(
|
|
projectTimeSunsetTimeOfDayDraft,
|
|
"Project sunset"
|
|
)
|
|
);
|
|
}
|
|
);
|
|
};
|
|
|
|
const applyProjectTimeDawnDurationHours = () => {
|
|
updateProjectTimeSettings(
|
|
"Set project dawn duration",
|
|
`Project dawn will blend over ${projectTimeDawnDurationHoursDraft} in-game hours.`,
|
|
(time) => {
|
|
time.dawnDurationHours = readPositiveNumberDraft(
|
|
projectTimeDawnDurationHoursDraft,
|
|
"Project dawn duration"
|
|
);
|
|
}
|
|
);
|
|
};
|
|
|
|
const applyProjectTimeDuskDurationHours = () => {
|
|
updateProjectTimeSettings(
|
|
"Set project dusk duration",
|
|
`Project dusk will blend over ${projectTimeDuskDurationHoursDraft} in-game hours.`,
|
|
(time) => {
|
|
time.duskDurationHours = readPositiveNumberDraft(
|
|
projectTimeDuskDurationHoursDraft,
|
|
"Project dusk duration"
|
|
);
|
|
}
|
|
);
|
|
};
|
|
|
|
const updateProjectScheduleRoutine = (
|
|
routineId: string,
|
|
label: string,
|
|
successMessage: string,
|
|
mutate: (routine: ProjectScheduleRoutine) => void
|
|
) => {
|
|
const currentRoutine =
|
|
editorState.projectDocument.scheduler.routines[routineId] ?? null;
|
|
|
|
if (currentRoutine === null) {
|
|
setStatusMessage("Selected sequence placement no longer exists.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const draftRoutine = cloneProjectScheduleRoutine(currentRoutine);
|
|
mutate(draftRoutine);
|
|
const nextRoutine = createProjectScheduleRoutine(draftRoutine);
|
|
const nextScheduler = upsertProjectScheduleRoutine(
|
|
cloneProjectScheduler(editorState.projectDocument.scheduler),
|
|
nextRoutine
|
|
);
|
|
applyProjectScheduler(nextScheduler, label, successMessage);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const resolveProjectScheduleTargetOption = (targetKey: string) =>
|
|
getProjectScheduleTargetOptionByKey(
|
|
projectScheduleTargetOptions,
|
|
targetKey
|
|
);
|
|
|
|
const createDefaultSequenceEffectsForTarget = (
|
|
targetOption: ProjectScheduleTargetOption
|
|
): ProjectSequence["effects"] => {
|
|
if (
|
|
targetOption.target.kind === "global" ||
|
|
targetOption.target.kind === "actor"
|
|
) {
|
|
return [];
|
|
}
|
|
|
|
const effectOption =
|
|
listProjectScheduleEffectOptions(targetOption)[0] ?? null;
|
|
|
|
if (effectOption === null) {
|
|
throw new Error(
|
|
"This target does not expose any direct sequencer effects in the current slice."
|
|
);
|
|
}
|
|
|
|
return [
|
|
{
|
|
stepClass: "held",
|
|
type: "controlEffect",
|
|
effect: createProjectScheduleEffectFromOption({
|
|
targetOption,
|
|
effectOptionId: effectOption.id
|
|
})
|
|
}
|
|
];
|
|
};
|
|
|
|
const createAttachedSequenceForRoutine = (options: {
|
|
title: string;
|
|
targetOption: ProjectScheduleTargetOption;
|
|
routine?: ProjectScheduleRoutine;
|
|
}): ProjectSequence => {
|
|
const authoredEffects =
|
|
options.routine === undefined
|
|
? []
|
|
: options.routine.effects
|
|
.map((effect) => ({
|
|
stepClass: "held" as const,
|
|
type: "controlEffect" as const,
|
|
effect: cloneControlEffect(effect)
|
|
}))
|
|
.filter((step) => step.effect.type !== "setActorPresence");
|
|
|
|
return createProjectSequence({
|
|
title: options.title,
|
|
effects:
|
|
authoredEffects.length > 0
|
|
? authoredEffects
|
|
: createDefaultSequenceEffectsForTarget(options.targetOption)
|
|
});
|
|
};
|
|
|
|
const cloneSequenceForRetargetedPlacement = (options: {
|
|
sequence: ProjectSequence;
|
|
targetOption: ProjectScheduleTargetOption;
|
|
previousTargetKey: string;
|
|
}): ProjectSequence => {
|
|
return createProjectSequence({
|
|
title: options.sequence.title,
|
|
effects: options.sequence.effects.map((effect) => {
|
|
if (
|
|
effect.type !== "controlEffect" ||
|
|
getControlTargetRefKey(effect.effect.target) !==
|
|
options.previousTargetKey
|
|
) {
|
|
return cloneSequenceEffect(effect);
|
|
}
|
|
|
|
return {
|
|
stepClass: effect.stepClass,
|
|
type: "controlEffect" as const,
|
|
effect: createProjectScheduleEffectFromOption({
|
|
targetOption: options.targetOption,
|
|
effectOptionId: getProjectScheduleEffectOptionId(effect.effect),
|
|
previousEffect: effect.effect
|
|
})
|
|
};
|
|
})
|
|
});
|
|
};
|
|
|
|
const handleCreateScheduleRoutine = (targetKey: string) => {
|
|
const targetOption = resolveProjectScheduleTargetOption(targetKey);
|
|
|
|
if (targetOption === null) {
|
|
setStatusMessage(
|
|
"Author a sequencer-addressable control target before creating a sequence placement."
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const nextSequence = createAttachedSequenceForRoutine({
|
|
title: targetOption.label,
|
|
targetOption
|
|
});
|
|
const nextRoutine = createProjectScheduleRoutine({
|
|
title: targetOption.label,
|
|
target: targetOption.target,
|
|
sequenceId: nextSequence.id,
|
|
days: createProjectScheduleEveryDaySelection(),
|
|
startHour: 9,
|
|
endHour: 17,
|
|
priority: 0,
|
|
effects: []
|
|
});
|
|
|
|
updateProjectSequencerState(
|
|
"Create sequence placement",
|
|
`Created a sequence placement for ${targetOption.label}.`,
|
|
(scheduler, sequences) => {
|
|
sequences.sequences[nextSequence.id] = nextSequence;
|
|
scheduler.routines[nextRoutine.id] = nextRoutine;
|
|
}
|
|
);
|
|
setSchedulePaneOpen(true);
|
|
setSequencerMode("timeline");
|
|
setSelectedScheduleRoutineId(nextRoutine.id);
|
|
setSelectedSequenceId(nextSequence.id);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const handleDeleteScheduleRoutine = (routineId: string) => {
|
|
const nextScheduler = removeProjectScheduleRoutine(
|
|
cloneProjectScheduler(editorState.projectDocument.scheduler),
|
|
routineId
|
|
);
|
|
|
|
applyProjectScheduler(
|
|
nextScheduler,
|
|
"Delete sequence placement",
|
|
"Deleted sequence placement."
|
|
);
|
|
if (selectedScheduleRoutineId === routineId) {
|
|
setSelectedScheduleRoutineId(null);
|
|
}
|
|
};
|
|
|
|
const handleCreateAttachedSequenceForRoutine = (routineId: string) => {
|
|
const routine =
|
|
editorState.projectDocument.scheduler.routines[routineId] ?? null;
|
|
|
|
if (routine === null) {
|
|
setStatusMessage("Selected sequence placement no longer exists.");
|
|
return;
|
|
}
|
|
|
|
const targetOption = resolveProjectScheduleTargetOption(
|
|
getControlTargetRefKey(routine.target)
|
|
);
|
|
|
|
if (targetOption === null) {
|
|
setStatusMessage("Selected sequencer target no longer exists.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const nextSequence = createAttachedSequenceForRoutine({
|
|
title: routine.title,
|
|
targetOption,
|
|
routine
|
|
});
|
|
|
|
updateProjectSequencerState(
|
|
"Create attached sequence",
|
|
"Created an attached sequence from this placement.",
|
|
(scheduler, sequences) => {
|
|
sequences.sequences[nextSequence.id] = nextSequence;
|
|
const draftRoutine = scheduler.routines[routineId];
|
|
|
|
if (draftRoutine === undefined) {
|
|
throw new Error("Selected sequence placement no longer exists.");
|
|
}
|
|
|
|
draftRoutine.sequenceId = nextSequence.id;
|
|
draftRoutine.effects = [];
|
|
}
|
|
);
|
|
setSelectedSequenceId(nextSequence.id);
|
|
setSequencerMode("timeline");
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const resolveSequenceControlTargetOption = (targetKey: string) =>
|
|
getProjectScheduleTargetOptionByKey(
|
|
projectScheduleTargetOptions,
|
|
targetKey
|
|
);
|
|
|
|
const createDefaultProjectSequenceControlStep = (
|
|
targetKey: string,
|
|
previousStep?: Extract<
|
|
ProjectSequence["effects"][number],
|
|
{ type: "controlEffect" }
|
|
> | null
|
|
): Extract<ProjectSequence["effects"][number], { type: "controlEffect" }> => {
|
|
const targetOption = resolveSequenceControlTargetOption(targetKey);
|
|
|
|
if (targetOption === null) {
|
|
throw new Error("The selected sequence control target no longer exists.");
|
|
}
|
|
|
|
const effectOption =
|
|
listProjectScheduleEffectOptions(targetOption)[0] ?? null;
|
|
|
|
if (effectOption === null) {
|
|
throw new Error(
|
|
"The selected target does not expose any direct sequence effects yet."
|
|
);
|
|
}
|
|
|
|
return {
|
|
stepClass: getProjectSequenceControlStepClassForEffectOptionId(
|
|
effectOption.id
|
|
),
|
|
type: "controlEffect",
|
|
effect: createProjectScheduleEffectFromOption({
|
|
targetOption,
|
|
effectOptionId: effectOption.id,
|
|
previousEffect: previousStep?.effect ?? null
|
|
})
|
|
};
|
|
};
|
|
|
|
const createProjectSequenceControlStepFromOption = (
|
|
targetKey: string,
|
|
effectOptionId: ProjectScheduleEffectOptionId,
|
|
previousStep?: Extract<
|
|
ProjectSequence["effects"][number],
|
|
{ type: "controlEffect" }
|
|
> | null
|
|
): Extract<ProjectSequence["effects"][number], { type: "controlEffect" }> => {
|
|
const targetOption = resolveSequenceControlTargetOption(targetKey);
|
|
|
|
if (targetOption === null) {
|
|
throw new Error("The selected sequence control target no longer exists.");
|
|
}
|
|
|
|
return {
|
|
stepClass:
|
|
getProjectSequenceControlStepClassForEffectOptionId(effectOptionId),
|
|
type: "controlEffect",
|
|
effect: createProjectScheduleEffectFromOption({
|
|
targetOption,
|
|
effectOptionId,
|
|
previousEffect: previousStep?.effect ?? null
|
|
})
|
|
};
|
|
};
|
|
|
|
const updateProjectSequence = (
|
|
sequenceId: string,
|
|
label: string,
|
|
successMessage: string,
|
|
mutate: (sequence: ProjectSequence) => void
|
|
) => {
|
|
updateProjectSequences(label, successMessage, (sequences) => {
|
|
const sequence = sequences.sequences[sequenceId];
|
|
|
|
if (sequence === undefined) {
|
|
throw new Error("Selected project sequence no longer exists.");
|
|
}
|
|
|
|
mutate(sequence);
|
|
});
|
|
};
|
|
|
|
const updateProjectSequenceStep = (
|
|
sequenceId: string,
|
|
stepIndex: number,
|
|
label: string,
|
|
successMessage: string,
|
|
mutate: (step: ProjectSequence["effects"][number]) => void
|
|
) => {
|
|
updateProjectSequence(sequenceId, label, successMessage, (sequence) => {
|
|
const step = sequence.effects[stepIndex];
|
|
|
|
if (step === undefined) {
|
|
throw new Error("Selected project sequence effect no longer exists.");
|
|
}
|
|
|
|
mutate(step);
|
|
});
|
|
};
|
|
|
|
const handleAddProjectSequence = () => {
|
|
const nextSequence = createProjectSequence();
|
|
|
|
updateProjectSequences(
|
|
"Add project sequence",
|
|
"Added project sequence.",
|
|
(sequences) => {
|
|
sequences.sequences[nextSequence.id] = nextSequence;
|
|
}
|
|
);
|
|
setSelectedSequenceId(nextSequence.id);
|
|
};
|
|
|
|
const handleDeleteProjectSequence = (sequenceId: string) => {
|
|
updateProjectSequences(
|
|
"Delete project sequence",
|
|
"Deleted project sequence.",
|
|
(sequences) => {
|
|
delete sequences.sequences[sequenceId];
|
|
}
|
|
);
|
|
|
|
if (selectedSequenceId === sequenceId) {
|
|
setSelectedSequenceId(null);
|
|
}
|
|
};
|
|
|
|
const openSequencerSequenceEditor = (sequenceId?: string | null) => {
|
|
setSchedulePaneOpen(true);
|
|
setSequencerMode("sequence");
|
|
|
|
if (sequenceId !== undefined) {
|
|
setSelectedSequenceId(sequenceId);
|
|
}
|
|
};
|
|
|
|
const handleAddNpcDialogue = () => {
|
|
const nextDialogue = createProjectDialogue();
|
|
|
|
updateSelectedNpcDialogues(
|
|
"Add NPC dialogue",
|
|
"Added NPC dialogue.",
|
|
(dialogues, defaultDialogueId) => ({
|
|
dialogues: [...dialogues, nextDialogue],
|
|
defaultDialogueId: defaultDialogueId ?? nextDialogue.id
|
|
})
|
|
);
|
|
setSelectedNpcDialogueId(nextDialogue.id);
|
|
};
|
|
|
|
const handleDeleteNpcDialogue = (dialogueId: string) => {
|
|
updateSelectedNpcDialogues(
|
|
"Delete NPC dialogue",
|
|
"Deleted NPC dialogue.",
|
|
(dialogues, defaultDialogueId) => {
|
|
const nextDialogues = dialogues.filter(
|
|
(dialogue) => dialogue.id !== dialogueId
|
|
);
|
|
return {
|
|
dialogues: nextDialogues,
|
|
defaultDialogueId:
|
|
defaultDialogueId === dialogueId
|
|
? (nextDialogues[0]?.id ?? null)
|
|
: defaultDialogueId
|
|
};
|
|
}
|
|
);
|
|
|
|
if (selectedNpcDialogueId === dialogueId) {
|
|
setSelectedNpcDialogueId(null);
|
|
}
|
|
};
|
|
|
|
const updateNpcDialogue = (
|
|
dialogueId: string,
|
|
label: string,
|
|
successMessage: string,
|
|
mutate: (dialogue: ProjectDialogue) => void
|
|
) => {
|
|
updateSelectedNpcDialogues(
|
|
label,
|
|
successMessage,
|
|
(dialogues, defaultDialogueId) => {
|
|
const nextDialogues = dialogues.map((dialogue) => {
|
|
if (dialogue.id !== dialogueId) {
|
|
return dialogue;
|
|
}
|
|
|
|
const nextDialogue: ProjectDialogue = {
|
|
...dialogue,
|
|
lines: dialogue.lines.map((line) => ({ ...line }))
|
|
};
|
|
mutate(nextDialogue);
|
|
return nextDialogue;
|
|
});
|
|
|
|
return {
|
|
dialogues: nextDialogues,
|
|
defaultDialogueId
|
|
};
|
|
}
|
|
);
|
|
};
|
|
|
|
const updateNpcDialogueLine = (
|
|
dialogueId: string,
|
|
lineId: string,
|
|
label: string,
|
|
successMessage: string,
|
|
mutate: (line: ProjectDialogueLine) => void
|
|
) => {
|
|
updateNpcDialogue(dialogueId, label, successMessage, (dialogue) => {
|
|
const line = dialogue.lines.find((candidate) => candidate.id === lineId);
|
|
|
|
if (line === undefined) {
|
|
throw new Error("Selected dialogue line no longer exists.");
|
|
}
|
|
|
|
mutate(line);
|
|
});
|
|
};
|
|
|
|
const handleAddProjectSequenceControlEffect = (
|
|
sequenceId: string,
|
|
targetKey: string,
|
|
effectOptionId: ProjectScheduleEffectOptionId
|
|
) => {
|
|
updateProjectSequence(
|
|
sequenceId,
|
|
"Add project sequence effect",
|
|
"Added sequence effect.",
|
|
(sequence) => {
|
|
sequence.effects.push(
|
|
createProjectSequenceControlStepFromOption(targetKey, effectOptionId)
|
|
);
|
|
}
|
|
);
|
|
};
|
|
|
|
const handleAddProjectSequenceNpcTalkStep = (
|
|
sequenceId: string,
|
|
npcEntityId: string,
|
|
dialogueId: string | null
|
|
) => {
|
|
updateProjectSequence(
|
|
sequenceId,
|
|
"Add project sequence NPC talk effect",
|
|
"Added NPC talk effect.",
|
|
(sequence) => {
|
|
sequence.effects.push({
|
|
stepClass: "impulse",
|
|
type: "makeNpcTalk",
|
|
npcEntityId,
|
|
dialogueId
|
|
});
|
|
}
|
|
);
|
|
};
|
|
|
|
const handleAddProjectSequenceTeleportStep = (
|
|
sequenceId: string,
|
|
targetEntityId: string
|
|
) => {
|
|
updateProjectSequence(
|
|
sequenceId,
|
|
"Add project sequence teleport effect",
|
|
"Added teleport effect.",
|
|
(sequence) => {
|
|
sequence.effects.push({
|
|
stepClass: "impulse",
|
|
type: "teleportPlayer",
|
|
targetEntityId
|
|
});
|
|
}
|
|
);
|
|
};
|
|
|
|
const handleAddProjectSequenceSceneTransitionStep = (
|
|
sequenceId: string,
|
|
targetKey: string
|
|
) => {
|
|
const target = readSceneTransitionTargetKey(targetKey);
|
|
|
|
updateProjectSequence(
|
|
sequenceId,
|
|
"Add project sequence scene transition effect",
|
|
"Added scene transition effect.",
|
|
(sequence) => {
|
|
sequence.effects.push({
|
|
stepClass: "impulse",
|
|
type: "startSceneTransition",
|
|
targetSceneId: target.targetSceneId,
|
|
targetEntryEntityId: target.targetEntryEntityId
|
|
});
|
|
}
|
|
);
|
|
};
|
|
|
|
const handleAddProjectSequenceVisibilityStep = (
|
|
sequenceId: string,
|
|
targetKey: string
|
|
) => {
|
|
const target = readSequenceVisibilityTargetKey(targetKey);
|
|
|
|
updateProjectSequence(
|
|
sequenceId,
|
|
"Add project sequence visibility effect",
|
|
"Added visibility effect.",
|
|
(sequence) => {
|
|
sequence.effects.push({
|
|
stepClass: "impulse",
|
|
type: "setVisibility",
|
|
target,
|
|
mode: "toggle"
|
|
});
|
|
}
|
|
);
|
|
};
|
|
|
|
const handleDeleteProjectSequenceStep = (
|
|
sequenceId: string,
|
|
stepIndex: number
|
|
) => {
|
|
updateProjectSequence(
|
|
sequenceId,
|
|
"Delete project sequence effect",
|
|
"Deleted sequence effect.",
|
|
(sequence) => {
|
|
sequence.effects.splice(stepIndex, 1);
|
|
}
|
|
);
|
|
};
|
|
|
|
const updateProjectSequenceControlStepTarget = (
|
|
sequenceId: string,
|
|
stepIndex: number,
|
|
targetKey: string
|
|
) => {
|
|
updateProjectSequenceStep(
|
|
sequenceId,
|
|
stepIndex,
|
|
"Set project sequence control target",
|
|
"Updated sequence control target.",
|
|
(step) => {
|
|
if (step.type !== "controlEffect") {
|
|
throw new Error("Only control steps expose a control target.");
|
|
}
|
|
|
|
const replacement = createDefaultProjectSequenceControlStep(
|
|
targetKey,
|
|
step
|
|
);
|
|
|
|
step.effect = replacement.effect;
|
|
}
|
|
);
|
|
};
|
|
|
|
const updateProjectSequenceControlStepEffectOption = (
|
|
sequenceId: string,
|
|
stepIndex: number,
|
|
effectOptionId: ProjectScheduleEffectOptionId
|
|
) => {
|
|
updateProjectSequenceStep(
|
|
sequenceId,
|
|
stepIndex,
|
|
"Set project sequence control effect",
|
|
"Updated sequence control effect.",
|
|
(step) => {
|
|
if (step.type !== "controlEffect") {
|
|
throw new Error("Only control steps expose a control effect.");
|
|
}
|
|
|
|
const targetOption = resolveSequenceControlTargetOption(
|
|
getControlTargetRefKey(step.effect.target)
|
|
);
|
|
|
|
if (targetOption === null) {
|
|
throw new Error(
|
|
"The current sequence control target no longer exists."
|
|
);
|
|
}
|
|
|
|
step.effect = createProjectScheduleEffectFromOption({
|
|
targetOption,
|
|
effectOptionId,
|
|
previousEffect: step.effect
|
|
});
|
|
step.stepClass =
|
|
getProjectSequenceControlStepClassForEffectOptionId(effectOptionId);
|
|
}
|
|
);
|
|
};
|
|
|
|
const updateProjectSequenceControlStepNumericValue = (
|
|
sequenceId: string,
|
|
stepIndex: number,
|
|
value: number
|
|
) => {
|
|
updateProjectSequenceStep(
|
|
sequenceId,
|
|
stepIndex,
|
|
"Set project sequence numeric value",
|
|
"Updated sequence numeric value.",
|
|
(step) => {
|
|
if (step.type !== "controlEffect") {
|
|
throw new Error("Only control steps expose numeric values.");
|
|
}
|
|
|
|
if (!Number.isFinite(value) || value < 0) {
|
|
throw new Error(
|
|
"Sequence numeric values must be finite and zero or greater."
|
|
);
|
|
}
|
|
|
|
switch (step.effect.type) {
|
|
case "setSoundVolume":
|
|
step.effect.volume = value;
|
|
return;
|
|
case "setLightIntensity":
|
|
case "setAmbientLightIntensity":
|
|
case "setSunLightIntensity":
|
|
step.effect.intensity = value;
|
|
return;
|
|
default:
|
|
throw new Error(
|
|
"The current sequence control effect does not expose a numeric value."
|
|
);
|
|
}
|
|
}
|
|
);
|
|
};
|
|
|
|
const updateProjectSequenceControlStepColorValue = (
|
|
sequenceId: string,
|
|
stepIndex: number,
|
|
colorHex: string
|
|
) => {
|
|
updateProjectSequenceStep(
|
|
sequenceId,
|
|
stepIndex,
|
|
"Set project sequence color value",
|
|
"Updated sequence color value.",
|
|
(step) => {
|
|
if (step.type !== "controlEffect") {
|
|
throw new Error("Only control steps expose color values.");
|
|
}
|
|
|
|
switch (step.effect.type) {
|
|
case "setLightColor":
|
|
case "setAmbientLightColor":
|
|
case "setSunLightColor":
|
|
step.effect.colorHex = colorHex;
|
|
return;
|
|
default:
|
|
throw new Error(
|
|
"The current sequence control effect does not expose a color value."
|
|
);
|
|
}
|
|
}
|
|
);
|
|
};
|
|
|
|
const updateProjectSequenceControlStepAnimationClip = (
|
|
sequenceId: string,
|
|
stepIndex: number,
|
|
clipName: string
|
|
) => {
|
|
updateProjectSequenceStep(
|
|
sequenceId,
|
|
stepIndex,
|
|
"Set project sequence animation clip",
|
|
"Updated sequence animation clip.",
|
|
(step) => {
|
|
if (
|
|
step.type !== "controlEffect" ||
|
|
(step.effect.type !== "playModelAnimation" &&
|
|
step.effect.type !== "playActorAnimation")
|
|
) {
|
|
throw new Error(
|
|
"The current sequence control step does not expose an animation clip."
|
|
);
|
|
}
|
|
|
|
step.effect.clipName = clipName;
|
|
}
|
|
);
|
|
};
|
|
|
|
const updateProjectSequenceControlStepAnimationLoop = (
|
|
sequenceId: string,
|
|
stepIndex: number,
|
|
loop: boolean
|
|
) => {
|
|
updateProjectSequenceStep(
|
|
sequenceId,
|
|
stepIndex,
|
|
"Set project sequence animation loop",
|
|
loop
|
|
? "Sequence animation now loops."
|
|
: "Sequence animation now plays once.",
|
|
(step) => {
|
|
if (
|
|
step.type !== "controlEffect" ||
|
|
(step.effect.type !== "playModelAnimation" &&
|
|
step.effect.type !== "playActorAnimation")
|
|
) {
|
|
throw new Error(
|
|
"The current sequence control step does not expose animation looping."
|
|
);
|
|
}
|
|
|
|
step.effect.loop = loop;
|
|
}
|
|
);
|
|
};
|
|
|
|
const updateProjectSequenceControlStepPathId = (
|
|
sequenceId: string,
|
|
stepIndex: number,
|
|
pathId: string
|
|
) => {
|
|
updateProjectSequenceStep(
|
|
sequenceId,
|
|
stepIndex,
|
|
"Set project sequence actor path",
|
|
"Updated sequence actor path.",
|
|
(step) => {
|
|
if (
|
|
step.type !== "controlEffect" ||
|
|
step.effect.type !== "followActorPath"
|
|
) {
|
|
throw new Error(
|
|
"The current sequence control step does not expose an actor path."
|
|
);
|
|
}
|
|
|
|
step.effect.pathId = pathId;
|
|
}
|
|
);
|
|
};
|
|
|
|
const updateProjectSequenceControlStepPathSpeed = (
|
|
sequenceId: string,
|
|
stepIndex: number,
|
|
speed: number
|
|
) => {
|
|
updateProjectSequenceStep(
|
|
sequenceId,
|
|
stepIndex,
|
|
"Set project sequence actor path speed",
|
|
"Updated sequence actor path speed.",
|
|
(step) => {
|
|
if (
|
|
step.type !== "controlEffect" ||
|
|
step.effect.type !== "followActorPath"
|
|
) {
|
|
throw new Error(
|
|
"The current sequence control step does not expose an actor path speed."
|
|
);
|
|
}
|
|
|
|
if (!Number.isFinite(speed) || speed <= 0) {
|
|
throw new Error(
|
|
"Actor path speed must be a finite number greater than zero."
|
|
);
|
|
}
|
|
|
|
step.effect.speed = speed;
|
|
}
|
|
);
|
|
};
|
|
|
|
const updateProjectSequenceControlStepPathLoop = (
|
|
sequenceId: string,
|
|
stepIndex: number,
|
|
loop: boolean
|
|
) => {
|
|
updateProjectSequenceStep(
|
|
sequenceId,
|
|
stepIndex,
|
|
"Set project sequence actor path loop",
|
|
loop
|
|
? "Sequence actor path now loops."
|
|
: "Sequence actor path now clamps at the end.",
|
|
(step) => {
|
|
if (
|
|
step.type !== "controlEffect" ||
|
|
step.effect.type !== "followActorPath"
|
|
) {
|
|
throw new Error(
|
|
"The current sequence control step does not expose actor path looping."
|
|
);
|
|
}
|
|
|
|
step.effect.loop = loop;
|
|
}
|
|
);
|
|
};
|
|
|
|
const updateProjectSequenceControlStepPathSmooth = (
|
|
sequenceId: string,
|
|
stepIndex: number,
|
|
smoothPath: boolean
|
|
) => {
|
|
updateProjectSequenceStep(
|
|
sequenceId,
|
|
stepIndex,
|
|
"Set project sequence actor path smoothing",
|
|
smoothPath
|
|
? "Sequence actor path now uses smoothing."
|
|
: "Sequence actor path now follows authored corners directly.",
|
|
(step) => {
|
|
if (
|
|
step.type !== "controlEffect" ||
|
|
step.effect.type !== "followActorPath"
|
|
) {
|
|
throw new Error(
|
|
"The current sequence control step does not expose actor path smoothing."
|
|
);
|
|
}
|
|
|
|
step.effect.smoothPath = smoothPath;
|
|
}
|
|
);
|
|
};
|
|
|
|
const updateProjectSequenceNpcTalkStepNpcEntityId = (
|
|
sequenceId: string,
|
|
stepIndex: number,
|
|
npcEntityId: string
|
|
) => {
|
|
updateProjectSequenceStep(
|
|
sequenceId,
|
|
stepIndex,
|
|
"Set project sequence talk NPC",
|
|
"Updated talking NPC.",
|
|
(step) => {
|
|
if (step.type !== "makeNpcTalk") {
|
|
throw new Error("Only NPC talk effects expose a talking NPC.");
|
|
}
|
|
|
|
const npcTargetOption =
|
|
npcDialogueSequenceTargetOptions.find(
|
|
(option) => option.npcEntityId === npcEntityId
|
|
) ?? null;
|
|
|
|
step.npcEntityId = npcEntityId;
|
|
step.dialogueId =
|
|
npcTargetOption?.defaultDialogueId ??
|
|
npcTargetOption?.dialogues[0]?.dialogueId ??
|
|
null;
|
|
}
|
|
);
|
|
};
|
|
|
|
const updateProjectSequenceNpcTalkStepDialogueId = (
|
|
sequenceId: string,
|
|
stepIndex: number,
|
|
dialogueId: string | null
|
|
) => {
|
|
updateProjectSequenceStep(
|
|
sequenceId,
|
|
stepIndex,
|
|
"Set project sequence NPC dialogue",
|
|
"Updated NPC dialogue.",
|
|
(step) => {
|
|
if (step.type !== "makeNpcTalk") {
|
|
throw new Error("Only NPC talk effects expose an NPC dialogue.");
|
|
}
|
|
|
|
step.dialogueId = dialogueId;
|
|
}
|
|
);
|
|
};
|
|
|
|
const updateProjectSequenceTeleportStepTarget = (
|
|
sequenceId: string,
|
|
stepIndex: number,
|
|
targetEntityId: string
|
|
) => {
|
|
updateProjectSequenceStep(
|
|
sequenceId,
|
|
stepIndex,
|
|
"Set project sequence teleport target",
|
|
"Updated teleport target.",
|
|
(step) => {
|
|
if (step.type !== "teleportPlayer") {
|
|
throw new Error("Only teleport effects expose a teleport target.");
|
|
}
|
|
|
|
step.targetEntityId = targetEntityId;
|
|
}
|
|
);
|
|
};
|
|
|
|
const updateProjectSequenceSceneTransitionStepTarget = (
|
|
sequenceId: string,
|
|
stepIndex: number,
|
|
targetKey: string
|
|
) => {
|
|
const target = readSceneTransitionTargetKey(targetKey);
|
|
|
|
updateProjectSequenceStep(
|
|
sequenceId,
|
|
stepIndex,
|
|
"Set project sequence scene transition target",
|
|
"Updated scene transition target.",
|
|
(step) => {
|
|
if (step.type !== "startSceneTransition") {
|
|
throw new Error(
|
|
"Only scene transition effects expose a scene transition target."
|
|
);
|
|
}
|
|
|
|
step.targetSceneId = target.targetSceneId;
|
|
step.targetEntryEntityId = target.targetEntryEntityId;
|
|
}
|
|
);
|
|
};
|
|
|
|
const updateProjectSequenceVisibilityStepTarget = (
|
|
sequenceId: string,
|
|
stepIndex: number,
|
|
targetKey: string
|
|
) => {
|
|
const target = readSequenceVisibilityTargetKey(targetKey);
|
|
|
|
updateProjectSequenceStep(
|
|
sequenceId,
|
|
stepIndex,
|
|
"Set project sequence visibility target",
|
|
"Updated visibility target.",
|
|
(step) => {
|
|
if (step.type !== "setVisibility") {
|
|
throw new Error(
|
|
"Only visibility effects expose a whitebox solid target."
|
|
);
|
|
}
|
|
|
|
step.target = target;
|
|
}
|
|
);
|
|
};
|
|
|
|
const updateProjectSequenceVisibilityStepMode = (
|
|
sequenceId: string,
|
|
stepIndex: number,
|
|
mode: "toggle" | "show" | "hide"
|
|
) => {
|
|
updateProjectSequenceStep(
|
|
sequenceId,
|
|
stepIndex,
|
|
"Set project sequence visibility mode",
|
|
"Updated visibility mode.",
|
|
(step) => {
|
|
if (step.type !== "setVisibility") {
|
|
throw new Error("Only visibility effects expose a visibility mode.");
|
|
}
|
|
|
|
step.mode = mode;
|
|
}
|
|
);
|
|
};
|
|
|
|
const updateWorldTimeOfDaySettings = (
|
|
label: string,
|
|
successMessage: string,
|
|
mutate: (world: WorldSettings) => void
|
|
) => {
|
|
const nextWorld = cloneWorldSettings(editorState.document.world);
|
|
mutate(nextWorld);
|
|
applyWorldSettings(nextWorld, label, successMessage);
|
|
};
|
|
|
|
const applyWorldTimePhaseColor = (
|
|
phase: WorldTimePhaseKey,
|
|
field: WorldTimePhaseColorField,
|
|
colorHex: string,
|
|
label: string,
|
|
successMessage: string
|
|
) => {
|
|
updateWorldTimeOfDaySettings(label, successMessage, (world) => {
|
|
world.timeOfDay[phase][field] = colorHex;
|
|
|
|
if (
|
|
(field === "skyTopColorHex" || field === "skyBottomColorHex") &&
|
|
world.timeOfDay[phase].background.mode === "verticalGradient"
|
|
) {
|
|
world.timeOfDay[phase].background =
|
|
field === "skyTopColorHex"
|
|
? {
|
|
...world.timeOfDay[phase].background,
|
|
topColorHex: colorHex
|
|
}
|
|
: {
|
|
...world.timeOfDay[phase].background,
|
|
bottomColorHex: colorHex
|
|
};
|
|
}
|
|
});
|
|
};
|
|
|
|
const applyWorldTimePhaseNumericField = (
|
|
phase: WorldTimePhaseKey,
|
|
field: WorldTimePhaseNumericField,
|
|
draftValue: string,
|
|
label: string,
|
|
draftLabel: string,
|
|
successMessage: string
|
|
) => {
|
|
try {
|
|
updateWorldTimeOfDaySettings(label, successMessage, (world) => {
|
|
world.timeOfDay[phase][field] = readNonNegativeNumberDraft(
|
|
draftValue,
|
|
draftLabel
|
|
);
|
|
});
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const applyWorldTimePhaseBackgroundMode = (
|
|
phase: WorldTimePhaseKey,
|
|
mode: WorldBackgroundMode,
|
|
imageAssetId?: string
|
|
) => {
|
|
const currentBackground =
|
|
editorState.document.world.timeOfDay[phase].background;
|
|
|
|
if (mode === "image") {
|
|
const currentBackgroundAssetId =
|
|
currentBackground.mode === "image" ? currentBackground.assetId : "";
|
|
const nextImageAssetId =
|
|
imageAssetId ??
|
|
(currentBackgroundAssetId.trim().length > 0 &&
|
|
editorState.document.assets[currentBackgroundAssetId]?.kind === "image"
|
|
? currentBackgroundAssetId
|
|
: (imageAssetList[0]?.id ?? ""));
|
|
|
|
updateWorldTimeOfDaySettings(
|
|
`Set ${phase} background image mode`,
|
|
nextImageAssetId.trim().length > 0
|
|
? `${phase[0].toUpperCase()}${phase.slice(1)} background set to ${
|
|
editorState.document.assets[nextImageAssetId]?.sourceName ??
|
|
nextImageAssetId
|
|
}.`
|
|
: `${phase[0].toUpperCase()}${phase.slice(1)} background now falls back to available day/night images when possible.`,
|
|
(world) => {
|
|
const profile = world.timeOfDay[phase];
|
|
profile.background = {
|
|
mode: "image",
|
|
assetId: nextImageAssetId,
|
|
environmentIntensity:
|
|
currentBackground.mode === "image"
|
|
? currentBackground.environmentIntensity
|
|
: DEFAULT_TIME_PHASE_IMAGE_ENVIRONMENT_INTENSITY
|
|
};
|
|
}
|
|
);
|
|
return;
|
|
}
|
|
|
|
updateWorldTimeOfDaySettings(
|
|
`Set ${phase} background mode`,
|
|
mode === "solid"
|
|
? `${phase[0].toUpperCase()}${phase.slice(1)} background set to a solid color.`
|
|
: `${phase[0].toUpperCase()}${phase.slice(1)} background set to a vertical gradient.`,
|
|
(world) => {
|
|
const profile = world.timeOfDay[phase];
|
|
if (mode === "solid") {
|
|
profile.background = {
|
|
mode: "solid",
|
|
colorHex:
|
|
profile.background.mode === "solid"
|
|
? profile.background.colorHex
|
|
: profile.background.mode === "verticalGradient"
|
|
? profile.background.topColorHex
|
|
: profile.skyTopColorHex
|
|
};
|
|
return;
|
|
}
|
|
|
|
profile.background = {
|
|
mode: "verticalGradient",
|
|
topColorHex: profile.skyTopColorHex,
|
|
bottomColorHex: profile.skyBottomColorHex
|
|
};
|
|
}
|
|
);
|
|
};
|
|
|
|
const applyWorldTimePhaseBackgroundColor = (
|
|
phase: WorldTimePhaseKey,
|
|
colorHex: string
|
|
) => {
|
|
if (
|
|
editorState.document.world.timeOfDay[phase].background.mode !== "solid"
|
|
) {
|
|
return;
|
|
}
|
|
|
|
updateWorldTimeOfDaySettings(
|
|
`Set ${phase} background color`,
|
|
`Updated the ${phase} background color.`,
|
|
(world) => {
|
|
world.timeOfDay[phase].background = {
|
|
mode: "solid",
|
|
colorHex
|
|
};
|
|
}
|
|
);
|
|
};
|
|
|
|
const applyWorldTimePhaseGradientColor = (
|
|
phase: WorldTimePhaseKey,
|
|
edge: "top" | "bottom",
|
|
colorHex: string
|
|
) => {
|
|
if (
|
|
editorState.document.world.timeOfDay[phase].background.mode !==
|
|
"verticalGradient"
|
|
) {
|
|
return;
|
|
}
|
|
|
|
updateWorldTimeOfDaySettings(
|
|
edge === "top"
|
|
? `Set ${phase} gradient top color`
|
|
: `Set ${phase} gradient bottom color`,
|
|
edge === "top"
|
|
? `Updated the ${phase} gradient top color.`
|
|
: `Updated the ${phase} gradient bottom color.`,
|
|
(world) => {
|
|
const profile = world.timeOfDay[phase];
|
|
|
|
if (profile.background.mode !== "verticalGradient") {
|
|
return;
|
|
}
|
|
|
|
profile.background =
|
|
edge === "top"
|
|
? {
|
|
...profile.background,
|
|
topColorHex: colorHex
|
|
}
|
|
: {
|
|
...profile.background,
|
|
bottomColorHex: colorHex
|
|
};
|
|
if (edge === "top") {
|
|
profile.skyTopColorHex = colorHex;
|
|
} else {
|
|
profile.skyBottomColorHex = colorHex;
|
|
}
|
|
}
|
|
);
|
|
};
|
|
|
|
const applyWorldTimePhaseBackgroundEnvironmentIntensity = (
|
|
phase: WorldTimePhaseKey,
|
|
draftValue: string
|
|
) => {
|
|
if (
|
|
editorState.document.world.timeOfDay[phase].background.mode !== "image"
|
|
) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
updateWorldTimeOfDaySettings(
|
|
`Set ${phase} background environment intensity`,
|
|
`Updated the ${phase} background environment intensity.`,
|
|
(world) => {
|
|
if (world.timeOfDay[phase].background.mode !== "image") {
|
|
return;
|
|
}
|
|
|
|
world.timeOfDay[phase].background = {
|
|
...world.timeOfDay[phase].background,
|
|
environmentIntensity: readNonNegativeNumberDraft(
|
|
draftValue,
|
|
`${phase[0].toUpperCase()}${phase.slice(1)} background environment intensity`
|
|
)
|
|
};
|
|
}
|
|
);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const applyWorldNightEnvironmentColor = (
|
|
field: WorldNightEnvironmentColorField,
|
|
colorHex: string,
|
|
label: string,
|
|
successMessage: string
|
|
) => {
|
|
updateWorldTimeOfDaySettings(label, successMessage, (world) => {
|
|
world.timeOfDay.night[field] = colorHex;
|
|
});
|
|
};
|
|
|
|
const applyWorldNightEnvironmentNumericField = (
|
|
field: WorldNightEnvironmentNumericField,
|
|
draftValue: string,
|
|
label: string,
|
|
draftLabel: string,
|
|
successMessage: string
|
|
) => {
|
|
try {
|
|
updateWorldTimeOfDaySettings(label, successMessage, (world) => {
|
|
world.timeOfDay.night[field] = readNonNegativeNumberDraft(
|
|
draftValue,
|
|
draftLabel
|
|
);
|
|
});
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const applyNightBackgroundMode = (
|
|
mode: WorldBackgroundMode,
|
|
imageAssetId?: string
|
|
) => {
|
|
const currentBackground =
|
|
editorState.document.world.timeOfDay.night.background;
|
|
|
|
if (mode === "image") {
|
|
const currentBackgroundAssetId =
|
|
currentBackground.mode === "image" ? currentBackground.assetId : null;
|
|
const nextImageAssetId =
|
|
imageAssetId ??
|
|
(currentBackgroundAssetId !== null &&
|
|
editorState.document.assets[currentBackgroundAssetId]?.kind === "image"
|
|
? currentBackgroundAssetId
|
|
: imageAssetList[0]?.id);
|
|
|
|
if (nextImageAssetId === undefined) {
|
|
setStatusMessage(
|
|
"Import an image asset before using a night image background."
|
|
);
|
|
return;
|
|
}
|
|
|
|
updateWorldTimeOfDaySettings(
|
|
"Set night background image",
|
|
`Night background set to ${
|
|
editorState.document.assets[nextImageAssetId]?.sourceName ??
|
|
nextImageAssetId
|
|
}.`,
|
|
(world) => {
|
|
world.timeOfDay.night.background = changeWorldBackgroundMode(
|
|
world.timeOfDay.night.background,
|
|
"image",
|
|
nextImageAssetId,
|
|
DEFAULT_NIGHT_IMAGE_ENVIRONMENT_INTENSITY
|
|
);
|
|
}
|
|
);
|
|
return;
|
|
}
|
|
|
|
updateWorldTimeOfDaySettings(
|
|
"Set night background mode",
|
|
mode === "solid"
|
|
? "Night background set to a solid color."
|
|
: "Night background set to a vertical gradient.",
|
|
(world) => {
|
|
world.timeOfDay.night.background = changeWorldBackgroundMode(
|
|
world.timeOfDay.night.background,
|
|
mode,
|
|
undefined,
|
|
DEFAULT_NIGHT_IMAGE_ENVIRONMENT_INTENSITY
|
|
);
|
|
}
|
|
);
|
|
};
|
|
|
|
const applyNightBackgroundColor = (colorHex: string) => {
|
|
if (
|
|
editorState.document.world.timeOfDay.night.background.mode !== "solid"
|
|
) {
|
|
return;
|
|
}
|
|
|
|
updateWorldTimeOfDaySettings(
|
|
"Set night background color",
|
|
"Updated the night background color.",
|
|
(world) => {
|
|
world.timeOfDay.night.background = {
|
|
mode: "solid",
|
|
colorHex
|
|
};
|
|
}
|
|
);
|
|
};
|
|
|
|
const applyNightGradientColor = (
|
|
edge: "top" | "bottom",
|
|
colorHex: string
|
|
) => {
|
|
if (
|
|
editorState.document.world.timeOfDay.night.background.mode !==
|
|
"verticalGradient"
|
|
) {
|
|
return;
|
|
}
|
|
|
|
updateWorldTimeOfDaySettings(
|
|
edge === "top"
|
|
? "Set night gradient top color"
|
|
: "Set night gradient bottom color",
|
|
edge === "top"
|
|
? "Updated the night gradient top color."
|
|
: "Updated the night gradient bottom color.",
|
|
(world) => {
|
|
if (world.timeOfDay.night.background.mode !== "verticalGradient") {
|
|
return;
|
|
}
|
|
|
|
world.timeOfDay.night.background =
|
|
edge === "top"
|
|
? {
|
|
...world.timeOfDay.night.background,
|
|
topColorHex: colorHex
|
|
}
|
|
: {
|
|
...world.timeOfDay.night.background,
|
|
bottomColorHex: colorHex
|
|
};
|
|
}
|
|
);
|
|
};
|
|
|
|
const applyNightBackgroundEnvironmentIntensity = () => {
|
|
if (
|
|
editorState.document.world.timeOfDay.night.background.mode !== "image"
|
|
) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
updateWorldTimeOfDaySettings(
|
|
"Set night background environment intensity",
|
|
"Updated the night background environment intensity.",
|
|
(world) => {
|
|
if (world.timeOfDay.night.background.mode !== "image") {
|
|
return;
|
|
}
|
|
|
|
world.timeOfDay.night.background = {
|
|
...world.timeOfDay.night.background,
|
|
environmentIntensity: readNonNegativeNumberDraft(
|
|
worldNightBackgroundEnvironmentIntensityDraft,
|
|
"Night background environment intensity"
|
|
)
|
|
};
|
|
}
|
|
);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const applySceneName = () => {
|
|
const normalizedName = sceneNameDraft.trim() || "Untitled Scene";
|
|
|
|
if (normalizedName === editorState.document.name) {
|
|
return;
|
|
}
|
|
|
|
store.executeCommand(createSetSceneNameCommand(normalizedName));
|
|
setStatusMessage(`Scene renamed to ${normalizedName}.`);
|
|
};
|
|
|
|
const handleCreateScene = () => {
|
|
store.executeCommand(createCreateSceneCommand());
|
|
setStatusMessage("Created a new scene.");
|
|
};
|
|
|
|
const handleActiveSceneChange = (event: ChangeEvent<HTMLSelectElement>) => {
|
|
const nextSceneId = event.currentTarget.value;
|
|
|
|
if (nextSceneId === editorState.activeSceneId) {
|
|
return;
|
|
}
|
|
|
|
const nextScene = editorState.projectDocument.scenes[nextSceneId];
|
|
|
|
if (nextScene === undefined) {
|
|
return;
|
|
}
|
|
|
|
store.executeCommand(createSetActiveSceneCommand(nextSceneId));
|
|
setStatusMessage(`Switched to scene ${nextScene.name}.`);
|
|
};
|
|
|
|
const applySceneLoadingScreen = (
|
|
loadingScreen: SceneLoadingScreenSettings,
|
|
label: string,
|
|
successMessage: string
|
|
) => {
|
|
if (
|
|
areSceneLoadingScreenSettingsEqual(
|
|
activeProjectScene.loadingScreen,
|
|
loadingScreen
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
store.executeCommand(
|
|
createSetSceneLoadingScreenCommand({
|
|
sceneId: activeProjectScene.id,
|
|
label,
|
|
loadingScreen
|
|
})
|
|
);
|
|
setStatusMessage(successMessage);
|
|
};
|
|
|
|
const applySceneLoadingColor = (colorHex: string) => {
|
|
applySceneLoadingScreen(
|
|
{
|
|
...cloneSceneLoadingScreenSettings(activeProjectScene.loadingScreen),
|
|
colorHex
|
|
},
|
|
"Update scene loading overlay color",
|
|
"Updated the runner loading overlay color."
|
|
);
|
|
};
|
|
|
|
const applySceneLoadingHeadline = () => {
|
|
const normalizedHeadline = sceneLoadingHeadlineDraft.trim();
|
|
|
|
applySceneLoadingScreen(
|
|
{
|
|
...cloneSceneLoadingScreenSettings(activeProjectScene.loadingScreen),
|
|
headline: normalizedHeadline.length === 0 ? null : normalizedHeadline
|
|
},
|
|
"Update scene loading overlay headline",
|
|
normalizedHeadline.length === 0
|
|
? "Cleared the runner loading overlay headline."
|
|
: "Updated the runner loading overlay headline."
|
|
);
|
|
};
|
|
|
|
const applySceneLoadingDescription = () => {
|
|
const normalizedDescription = sceneLoadingDescriptionDraft.trim();
|
|
|
|
applySceneLoadingScreen(
|
|
{
|
|
...cloneSceneLoadingScreenSettings(activeProjectScene.loadingScreen),
|
|
description:
|
|
normalizedDescription.length === 0 ? null : normalizedDescription
|
|
},
|
|
"Update scene loading overlay description",
|
|
normalizedDescription.length === 0
|
|
? "Cleared the runner loading overlay description."
|
|
: "Updated the runner loading overlay description."
|
|
);
|
|
};
|
|
|
|
const requestViewportFocus = (
|
|
selection: EditorSelection,
|
|
status?: string
|
|
) => {
|
|
setFocusRequest((current) => ({
|
|
id: current.id + 1,
|
|
panelId: activePanelId,
|
|
selection
|
|
}));
|
|
|
|
if (status !== undefined) {
|
|
setStatusMessage(status);
|
|
}
|
|
};
|
|
|
|
const openAddMenuAt = (position: HierarchicalMenuPosition) => {
|
|
setHoveredAssetId(null);
|
|
setAddMenuPosition(position);
|
|
};
|
|
|
|
const closeAddMenu = () => {
|
|
setHoveredAssetId(null);
|
|
setAddMenuPosition(null);
|
|
};
|
|
|
|
const handleOpenAddMenuFromButton = (
|
|
event: ReactMouseEvent<HTMLButtonElement>
|
|
) => {
|
|
const rect = event.currentTarget.getBoundingClientRect();
|
|
|
|
openAddMenuAt({
|
|
x: rect.left,
|
|
y: rect.bottom + 8
|
|
});
|
|
};
|
|
|
|
const handleSetViewportLayoutMode = (nextLayoutMode: ViewportLayoutMode) => {
|
|
if (editorState.viewportLayoutMode === nextLayoutMode) {
|
|
return;
|
|
}
|
|
|
|
blurActiveTextEntry();
|
|
store.setViewportLayoutMode(nextLayoutMode);
|
|
setStatusMessage(
|
|
`Switched the viewport to ${getViewportLayoutModeLabel(nextLayoutMode)}.`
|
|
);
|
|
};
|
|
|
|
const handleActivateViewportPanel = (panelId: ViewportPanelId) => {
|
|
if (editorState.activeViewportPanelId === panelId) {
|
|
return;
|
|
}
|
|
|
|
blurActiveTextEntry();
|
|
store.setActiveViewportPanel(panelId);
|
|
setStatusMessage("Activated the viewport panel.");
|
|
};
|
|
|
|
const handleSetViewportPanelViewMode = (
|
|
panelId: ViewportPanelId,
|
|
nextViewMode: ViewportViewMode
|
|
) => {
|
|
if (editorState.viewportPanels[panelId].viewMode === nextViewMode) {
|
|
return;
|
|
}
|
|
|
|
blurActiveTextEntry();
|
|
store.setViewportPanelViewMode(panelId, nextViewMode);
|
|
|
|
setStatusMessage(
|
|
`Set the viewport panel to ${getViewportViewModeLabel(nextViewMode)} view.`
|
|
);
|
|
};
|
|
|
|
const handleSetViewportPanelDisplayMode = (
|
|
panelId: ViewportPanelId,
|
|
nextDisplayMode: ViewportDisplayMode
|
|
) => {
|
|
if (editorState.viewportPanels[panelId].displayMode === nextDisplayMode) {
|
|
return;
|
|
}
|
|
|
|
blurActiveTextEntry();
|
|
store.setViewportPanelDisplayMode(panelId, nextDisplayMode);
|
|
setStatusMessage(
|
|
`Set the viewport panel to ${getViewportDisplayModeLabel(nextDisplayMode)} display.`
|
|
);
|
|
};
|
|
|
|
const handleUndo = () => {
|
|
if (store.undo()) {
|
|
setStatusMessage("Undid the last action.");
|
|
} else {
|
|
setStatusMessage("Nothing to undo.");
|
|
}
|
|
};
|
|
|
|
const handleRedo = () => {
|
|
if (store.redo()) {
|
|
setStatusMessage("Redid the last action.");
|
|
} else {
|
|
setStatusMessage("Nothing to redo.");
|
|
}
|
|
};
|
|
|
|
const beginTransformOperation = (
|
|
operation: TransformOperation,
|
|
source: TransformSessionSource
|
|
) => {
|
|
if (editorState.toolMode !== "select") {
|
|
return;
|
|
}
|
|
|
|
const transformSourcePanelId =
|
|
layoutMode === "quad"
|
|
? (hoveredViewportPanelId ?? activePanelId)
|
|
: activePanelId;
|
|
|
|
const transformTargetResult = resolveTransformTarget(
|
|
editorState.document,
|
|
editorState.selection,
|
|
whiteboxSelectionMode,
|
|
editorState.activeSelectionId
|
|
);
|
|
const transformTarget = transformTargetResult.target;
|
|
|
|
if (transformTarget === null) {
|
|
setStatusMessage(
|
|
transformTargetResult.message ??
|
|
"Select a single brush, entity, or model instance before transforming it."
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (!supportsTransformOperation(transformTarget, operation)) {
|
|
setStatusMessage(
|
|
`${getTransformOperationLabel(operation)} is not supported for ${getTransformTargetLabel(transformTarget)}.`
|
|
);
|
|
return;
|
|
}
|
|
|
|
blurActiveTextEntry();
|
|
closeAddMenu();
|
|
|
|
if (editorState.activeViewportPanelId !== transformSourcePanelId) {
|
|
store.setActiveViewportPanel(transformSourcePanelId);
|
|
}
|
|
|
|
const nextTransformSession = createTransformSession({
|
|
source,
|
|
sourcePanelId: transformSourcePanelId,
|
|
operation,
|
|
target: transformTarget
|
|
});
|
|
latestActiveTransformSessionRef.current = nextTransformSession;
|
|
store.setTransformSession(nextTransformSession);
|
|
setStatusMessage(
|
|
`${getTransformOperationLabel(operation)} ${getTransformTargetLabel(transformTarget).toLowerCase()} in ${getViewportPanelLabel(
|
|
transformSourcePanelId
|
|
)}. Move the pointer, press X/Y/Z to constrain, press the same axis again for local when supported, click or press Enter to commit, Escape cancels.`
|
|
);
|
|
};
|
|
|
|
const cancelTransformSession = (
|
|
status = "Cancelled the current transform."
|
|
) => {
|
|
if (transformSession.kind === "none") {
|
|
return;
|
|
}
|
|
|
|
latestActiveTransformSessionRef.current = null;
|
|
store.clearTransformSession();
|
|
setStatusMessage(status);
|
|
};
|
|
|
|
const commitTransformSession = (
|
|
activeTransformSession: ActiveTransformSession
|
|
) => {
|
|
if (!doesTransformSessionChangeTarget(activeTransformSession)) {
|
|
latestActiveTransformSessionRef.current = null;
|
|
store.clearTransformSession();
|
|
setStatusMessage("No transform change was committed.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
latestActiveTransformSessionRef.current = null;
|
|
store.clearTransformSession();
|
|
store.executeCommand(
|
|
createCommitTransformSessionCommand(
|
|
editorState.document,
|
|
activeTransformSession
|
|
)
|
|
);
|
|
setStatusMessage(
|
|
`${getTransformOperationPastTense(activeTransformSession.operation)} ${getTransformTargetLabel(activeTransformSession.target).toLowerCase()}.`
|
|
);
|
|
} catch (error) {
|
|
latestActiveTransformSessionRef.current = null;
|
|
store.clearTransformSession();
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const applyTransformAxisConstraint = (axis: TransformAxis) => {
|
|
if (transformSession.kind !== "active") {
|
|
return;
|
|
}
|
|
|
|
if (!supportsTransformAxisConstraint(transformSession, axis)) {
|
|
const supportedAxes = (["x", "y", "z"] as const)
|
|
.filter((candidateAxis) =>
|
|
supportsTransformAxisConstraint(transformSession, candidateAxis)
|
|
)
|
|
.map((candidateAxis) => candidateAxis.toUpperCase())
|
|
.join("/");
|
|
setStatusMessage(
|
|
supportedAxes.length === 0
|
|
? `${getTransformOperationLabel(transformSession.operation)} does not support axis constraints for ${getTransformTargetLabel(transformSession.target)}.`
|
|
: `${getTransformOperationLabel(transformSession.operation)} on ${getTransformTargetLabel(transformSession.target)} only supports ${supportedAxes}.`
|
|
);
|
|
return;
|
|
}
|
|
|
|
const nextAxisConstraintSpace =
|
|
transformSession.axisConstraint === axis
|
|
? transformSession.axisConstraintSpace === "world"
|
|
? "local"
|
|
: "world"
|
|
: "world";
|
|
|
|
if (
|
|
nextAxisConstraintSpace === "local" &&
|
|
!supportsLocalTransformAxisConstraint(transformSession, axis)
|
|
) {
|
|
setStatusMessage(
|
|
`Local ${getTransformAxisLabel(axis)} is not supported for ${getTransformOperationLabel(transformSession.operation).toLowerCase()} on ${getTransformTargetLabel(
|
|
transformSession.target
|
|
)}.`
|
|
);
|
|
return;
|
|
}
|
|
|
|
store.setTransformAxisConstraint(axis, nextAxisConstraintSpace);
|
|
setStatusMessage(
|
|
`Constrained ${getTransformOperationLabel(transformSession.operation).toLowerCase()} to ${getTransformAxisSpaceLabel(
|
|
nextAxisConstraintSpace
|
|
).toLowerCase()} ${getTransformAxisLabel(axis)}.`
|
|
);
|
|
};
|
|
|
|
const toggleTransformSurfaceSnap = () => {
|
|
if (
|
|
transformSession.kind !== "active" ||
|
|
transformSession.operation !== "translate"
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (!supportsTransformSurfaceSnapTarget(transformSession.target)) {
|
|
setStatusMessage(
|
|
`Surface Snap Move is not available for ${getTransformTargetLabel(transformSession.target).toLowerCase()}.`
|
|
);
|
|
return;
|
|
}
|
|
|
|
const nextSurfaceSnapEnabled = !transformSession.surfaceSnapEnabled;
|
|
store.setTransformSession({
|
|
...transformSession,
|
|
surfaceSnapEnabled: nextSurfaceSnapEnabled
|
|
});
|
|
setStatusMessage(
|
|
nextSurfaceSnapEnabled
|
|
? "Enabled Surface Snap Move."
|
|
: "Disabled Surface Snap Move."
|
|
);
|
|
};
|
|
|
|
const handleViewportQuadResizeStart =
|
|
(resizeMode: ViewportQuadResizeMode) =>
|
|
(event: ReactPointerEvent<HTMLDivElement>) => {
|
|
if (layoutMode !== "quad") {
|
|
return;
|
|
}
|
|
|
|
const viewportPanels = viewportPanelsRef.current;
|
|
|
|
if (viewportPanels === null) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
blurActiveTextEntry();
|
|
|
|
const rect = viewportPanels.getBoundingClientRect();
|
|
|
|
if (rect.width > 0 && rect.height > 0) {
|
|
const nextViewportQuadSplit = {
|
|
...viewportQuadSplitRef.current
|
|
};
|
|
|
|
if (resizeMode !== "horizontal") {
|
|
nextViewportQuadSplit.x = clampViewportQuadSplitValue(
|
|
(event.clientX - rect.left) / rect.width
|
|
);
|
|
}
|
|
|
|
if (resizeMode !== "vertical") {
|
|
nextViewportQuadSplit.y = clampViewportQuadSplitValue(
|
|
(event.clientY - rect.top) / rect.height
|
|
);
|
|
}
|
|
|
|
store.setViewportQuadSplit(nextViewportQuadSplit);
|
|
}
|
|
|
|
setViewportQuadResizeMode(resizeMode);
|
|
};
|
|
|
|
const handleSchedulePaneResizeStart = (
|
|
event: ReactPointerEvent<HTMLDivElement>
|
|
) => {
|
|
if (!schedulePaneOpen) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
blurActiveTextEntry();
|
|
schedulePaneResizeStartRef.current = {
|
|
startY: event.clientY,
|
|
startHeight: schedulePaneHeightRef.current
|
|
};
|
|
setSchedulePaneResizeActive(true);
|
|
};
|
|
|
|
const handleSchedulePaneResizeKeyDown = (
|
|
event: ReactKeyboardEvent<HTMLDivElement>
|
|
) => {
|
|
if (!schedulePaneOpen) {
|
|
return;
|
|
}
|
|
|
|
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
const delta = event.key === "ArrowUp" ? 24 : -24;
|
|
commitSchedulePaneHeight(schedulePaneHeightRef.current + delta);
|
|
};
|
|
|
|
const beginCreation = (
|
|
toolPreview: CreationViewportToolPreview,
|
|
status: string
|
|
) => {
|
|
blurActiveTextEntry();
|
|
closeAddMenu();
|
|
store.setToolMode("create");
|
|
store.setViewportToolPreview(toolPreview);
|
|
setStatusMessage(status);
|
|
};
|
|
|
|
const completeCreation = (status: string) => {
|
|
store.setToolMode("select");
|
|
store.clearViewportToolPreview();
|
|
setStatusMessage(status);
|
|
};
|
|
|
|
const beginBoxCreation = () => {
|
|
beginCreation(
|
|
{
|
|
kind: "create",
|
|
sourcePanelId: activePanelId,
|
|
target: {
|
|
kind: "box-brush"
|
|
},
|
|
center: null
|
|
},
|
|
`Previewing a whitebox box. Click in the viewport to create it${whiteboxSnapEnabled ? ` on the ${whiteboxSnapStep}m grid` : ""}.`
|
|
);
|
|
};
|
|
|
|
const beginWedgeCreation = () => {
|
|
beginCreation(
|
|
{
|
|
kind: "create",
|
|
sourcePanelId: activePanelId,
|
|
target: {
|
|
kind: "wedge-brush"
|
|
},
|
|
center: null
|
|
},
|
|
`Previewing a whitebox wedge. Click in the viewport to create it${whiteboxSnapEnabled ? ` on the ${whiteboxSnapStep}m grid` : ""}.`
|
|
);
|
|
};
|
|
|
|
const beginCylinderCreation = () => {
|
|
beginCreation(
|
|
{
|
|
kind: "create",
|
|
sourcePanelId: activePanelId,
|
|
target: {
|
|
kind: "cylinder-brush",
|
|
sideCount: 12
|
|
},
|
|
center: null
|
|
},
|
|
`Previewing a whitebox cylinder. Click in the viewport to create it${whiteboxSnapEnabled ? ` on the ${whiteboxSnapStep}m grid` : ""}.`
|
|
);
|
|
};
|
|
|
|
const beginConeCreation = () => {
|
|
beginCreation(
|
|
{
|
|
kind: "create",
|
|
sourcePanelId: activePanelId,
|
|
target: {
|
|
kind: "cone-brush",
|
|
sideCount: DEFAULT_CONE_SIDE_COUNT
|
|
},
|
|
center: null
|
|
},
|
|
`Previewing a whitebox cone. Click in the viewport to create it${whiteboxSnapEnabled ? ` on the ${whiteboxSnapStep}m grid` : ""}.`
|
|
);
|
|
};
|
|
|
|
const beginTorusCreation = () => {
|
|
beginCreation(
|
|
{
|
|
kind: "create",
|
|
sourcePanelId: activePanelId,
|
|
target: {
|
|
kind: "torus-brush",
|
|
majorSegmentCount: DEFAULT_TORUS_MAJOR_SEGMENT_COUNT,
|
|
tubeSegmentCount: DEFAULT_TORUS_TUBE_SEGMENT_COUNT
|
|
},
|
|
center: null
|
|
},
|
|
`Previewing a whitebox torus. Click in the viewport to create it${whiteboxSnapEnabled ? ` on the ${whiteboxSnapStep}m grid` : ""}.`
|
|
);
|
|
};
|
|
|
|
const handleWhiteboxSnapToggle = () => {
|
|
const nextEnabled = !whiteboxSnapEnabled;
|
|
store.setWhiteboxSnapEnabled(nextEnabled);
|
|
setStatusMessage(
|
|
nextEnabled
|
|
? `Grid snap enabled at ${whiteboxSnapStep}m.`
|
|
: "Grid snap disabled for whitebox transforms."
|
|
);
|
|
};
|
|
|
|
const handleViewportGridToggle = () => {
|
|
const nextVisible = !viewportGridVisible;
|
|
store.setViewportGridVisible(nextVisible);
|
|
setStatusMessage(
|
|
nextVisible ? "Viewport grid enabled." : "Viewport grid hidden."
|
|
);
|
|
};
|
|
|
|
const handleWhiteboxSnapStepBlur = () => {
|
|
const normalizedStep = resolveOptionalPositiveNumber(
|
|
whiteboxSnapStepDraft,
|
|
DEFAULT_GRID_SIZE
|
|
);
|
|
setWhiteboxSnapStepDraft(String(normalizedStep));
|
|
store.setWhiteboxSnapStep(normalizedStep);
|
|
};
|
|
|
|
const handleWhiteboxSelectionModeChange = (mode: WhiteboxSelectionMode) => {
|
|
if (whiteboxSelectionMode === mode) {
|
|
return;
|
|
}
|
|
|
|
blurActiveTextEntry();
|
|
store.setWhiteboxSelectionMode(mode);
|
|
setStatusMessage(getWhiteboxSelectionModeStatus(mode));
|
|
};
|
|
|
|
const applySelection = (
|
|
selection: EditorSelection,
|
|
source: "outliner" | "viewport" | "inspector" | "runner",
|
|
options: { focusViewport?: boolean } = {}
|
|
) => {
|
|
blurActiveTextEntry();
|
|
store.setSelection(selection);
|
|
const activeSelectionId = getSelectionDefaultActiveId(selection);
|
|
|
|
const suffix =
|
|
source === "outliner" && options.focusViewport
|
|
? " and framed it in the viewport"
|
|
: "";
|
|
|
|
switch (selection.kind) {
|
|
case "none":
|
|
setStatusMessage(
|
|
`${source === "viewport" ? "Viewport" : "Editor"} selection cleared${suffix}.`
|
|
);
|
|
break;
|
|
case "brushes":
|
|
setStatusMessage(
|
|
selection.ids.length === 1
|
|
? `Selected ${getBrushLabelById(selection.ids[0], brushList)} from the ${source}${suffix}.`
|
|
: `Selected ${selection.ids.length} whitebox solids from the ${source}${suffix}. Active: ${getBrushLabelById(activeSelectionId ?? selection.ids.at(-1) ?? selection.ids[0], brushList)}.`
|
|
);
|
|
break;
|
|
case "brushFace":
|
|
setStatusMessage(
|
|
`Selected ${
|
|
brushList.find((brush) => brush.id === selection.brushId) ===
|
|
undefined
|
|
? selection.faceId
|
|
: getBrushFaceLabel(
|
|
brushList.find((brush) => brush.id === selection.brushId)!,
|
|
selection.faceId
|
|
)
|
|
} on ${getBrushLabelById(selection.brushId, brushList)} from the ${source}${suffix}.`
|
|
);
|
|
break;
|
|
case "brushEdge":
|
|
setStatusMessage(
|
|
`Selected ${
|
|
brushList.find((brush) => brush.id === selection.brushId) ===
|
|
undefined
|
|
? selection.edgeId
|
|
: getBrushEdgeLabel(
|
|
brushList.find((brush) => brush.id === selection.brushId)!,
|
|
selection.edgeId
|
|
)
|
|
} on ${getBrushLabelById(selection.brushId, brushList)} from the ${source}${suffix}.`
|
|
);
|
|
break;
|
|
case "brushVertex":
|
|
setStatusMessage(
|
|
`Selected ${
|
|
brushList.find((brush) => brush.id === selection.brushId) ===
|
|
undefined
|
|
? selection.vertexId
|
|
: getBrushVertexLabel(
|
|
brushList.find((brush) => brush.id === selection.brushId)!,
|
|
selection.vertexId
|
|
)
|
|
} on ${getBrushLabelById(selection.brushId, brushList)} from the ${source}${suffix}.`
|
|
);
|
|
break;
|
|
case "terrains":
|
|
setStatusMessage(
|
|
selection.ids.length === 1
|
|
? `Selected ${getTerrainLabelById(selection.ids[0], terrainList)} from the ${source}${suffix}.`
|
|
: `Selected ${selection.ids.length} terrains from the ${source}${suffix}.`
|
|
);
|
|
break;
|
|
case "paths":
|
|
setStatusMessage(
|
|
`Selected ${getPathLabelById(selection.ids[0], pathList)} from the ${source}${suffix}.`
|
|
);
|
|
break;
|
|
case "pathPoint": {
|
|
const pointIndex =
|
|
selectedPath !== null && selectedPath.id === selection.pathId
|
|
? getScenePathPointIndex(selectedPath, selection.pointId)
|
|
: getScenePathPointIndex(
|
|
editorState.document.paths[selection.pathId] ?? {
|
|
points: []
|
|
},
|
|
selection.pointId
|
|
);
|
|
|
|
setStatusMessage(
|
|
`Selected path point ${pointIndex === -1 ? selection.pointId : pointIndex + 1} on ${getPathLabelById(selection.pathId, pathList)} from the ${source}${suffix}.`
|
|
);
|
|
break;
|
|
}
|
|
case "entities":
|
|
setStatusMessage(
|
|
selection.ids.length === 1
|
|
? `Selected ${getEntityDisplayLabelById(selection.ids[0], editorState.document.entities, editorState.document.assets)} from the ${source}${suffix}.`
|
|
: `Selected ${selection.ids.length} entities from the ${source}${suffix}. Active: ${getEntityDisplayLabelById(activeSelectionId ?? selection.ids.at(-1) ?? selection.ids[0], editorState.document.entities, editorState.document.assets)}.`
|
|
);
|
|
break;
|
|
case "modelInstances":
|
|
setStatusMessage(
|
|
selection.ids.length === 1
|
|
? `Selected ${getModelInstanceDisplayLabelById(selection.ids[0], editorState.document.modelInstances, editorState.document.assets)} from the ${source}${suffix}.`
|
|
: `Selected ${selection.ids.length} model instances from the ${source}${suffix}. Active: ${getModelInstanceDisplayLabelById(activeSelectionId ?? selection.ids.at(-1) ?? selection.ids[0], editorState.document.modelInstances, editorState.document.assets)}.`
|
|
);
|
|
break;
|
|
default:
|
|
setStatusMessage(`Selection updated from the ${source}${suffix}.`);
|
|
break;
|
|
}
|
|
|
|
if (options.focusViewport) {
|
|
requestViewportFocus(selection);
|
|
}
|
|
};
|
|
|
|
const applyPositionChange = () => {
|
|
if (
|
|
selectedBrush === null ||
|
|
editorState.selection.kind !== "brushes" ||
|
|
whiteboxSelectionMode !== "object"
|
|
) {
|
|
setStatusMessage(
|
|
"Switch to Object mode and select a whitebox box before moving it."
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const nextCenter = maybeSnapVec3(
|
|
readVec3Draft(positionDraft, "Whitebox box position"),
|
|
whiteboxSnapEnabled,
|
|
whiteboxSnapStep
|
|
);
|
|
|
|
if (areVec3Equal(nextCenter, selectedBrush.center)) {
|
|
return;
|
|
}
|
|
|
|
store.executeCommand(
|
|
createMoveBoxBrushCommand({
|
|
brushId: selectedBrush.id,
|
|
center: nextCenter,
|
|
snapToGrid: false
|
|
})
|
|
);
|
|
setStatusMessage("Moved selected whitebox box.");
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const applyRotationChange = () => {
|
|
if (
|
|
selectedBrush === null ||
|
|
editorState.selection.kind !== "brushes" ||
|
|
whiteboxSelectionMode !== "object"
|
|
) {
|
|
setStatusMessage(
|
|
"Switch to Object mode and select a whitebox box before rotating it."
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const nextRotationDegrees = readVec3Draft(
|
|
rotationDraft,
|
|
"Whitebox box rotation"
|
|
);
|
|
|
|
if (areVec3Equal(nextRotationDegrees, selectedBrush.rotationDegrees)) {
|
|
return;
|
|
}
|
|
|
|
store.executeCommand(
|
|
createRotateBoxBrushCommand({
|
|
brushId: selectedBrush.id,
|
|
rotationDegrees: nextRotationDegrees
|
|
})
|
|
);
|
|
setStatusMessage("Rotated selected whitebox box.");
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const applySizeChange = () => {
|
|
if (
|
|
selectedBrush === null ||
|
|
editorState.selection.kind !== "brushes" ||
|
|
whiteboxSelectionMode !== "object"
|
|
) {
|
|
setStatusMessage(
|
|
"Switch to Object mode and select a whitebox box before scaling it."
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const nextSize = maybeSnapPositiveSize(
|
|
readVec3Draft(sizeDraft, "Whitebox box size"),
|
|
whiteboxSnapEnabled,
|
|
whiteboxSnapStep
|
|
);
|
|
|
|
if (areVec3Equal(nextSize, selectedBrush.size)) {
|
|
return;
|
|
}
|
|
|
|
store.executeCommand(
|
|
createResizeBoxBrushCommand({
|
|
brushId: selectedBrush.id,
|
|
size: nextSize,
|
|
snapToGrid: false
|
|
})
|
|
);
|
|
setStatusMessage("Scaled selected whitebox box.");
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const applyBoxVolumeSettings = (
|
|
mutate: (next: BoxBrush["volume"]) => BoxBrush["volume"],
|
|
label: string,
|
|
successMessage: string
|
|
) => {
|
|
if (selectedBrush === null || selectedBrush.kind !== "box") {
|
|
setStatusMessage(
|
|
"Select a whitebox box before editing box volume settings."
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const nextVolume = mutate(selectedBrush.volume);
|
|
|
|
store.executeCommand(
|
|
createSetBoxBrushVolumeSettingsCommand({
|
|
brushId: selectedBrush.id,
|
|
volume: nextVolume,
|
|
label
|
|
})
|
|
);
|
|
setStatusMessage(successMessage);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const applyBoxVolumeModeChange = (mode: BoxBrushVolumeMode) => {
|
|
if (selectedBrush === null) {
|
|
setStatusMessage(
|
|
"Select a whitebox box before changing the volume mode."
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (selectedBrush.volume.mode === mode) {
|
|
return;
|
|
}
|
|
|
|
applyBoxVolumeSettings(
|
|
(currentVolume) => {
|
|
switch (mode) {
|
|
case "none":
|
|
return {
|
|
mode: "none"
|
|
};
|
|
case "water":
|
|
return currentVolume.mode === "water"
|
|
? currentVolume
|
|
: {
|
|
mode: "water",
|
|
water: {
|
|
colorHex: boxVolumeWaterColorDraft,
|
|
surfaceOpacity: readNonNegativeNumberDraft(
|
|
boxVolumeWaterSurfaceOpacityDraft,
|
|
"Water surface opacity"
|
|
),
|
|
waveStrength: readNonNegativeNumberDraft(
|
|
boxVolumeWaterWaveStrengthDraft,
|
|
"Water wave strength"
|
|
),
|
|
foamContactLimit: readWaterFoamContactLimitDraft(
|
|
boxVolumeWaterFoamContactLimitDraft
|
|
),
|
|
surfaceDisplacementEnabled:
|
|
boxVolumeWaterSurfaceDisplacementEnabledDraft
|
|
}
|
|
};
|
|
case "fog":
|
|
return currentVolume.mode === "fog"
|
|
? currentVolume
|
|
: {
|
|
mode: "fog",
|
|
fog: {
|
|
colorHex: boxVolumeFogColorDraft,
|
|
density: readNonNegativeNumberDraft(
|
|
boxVolumeFogDensityDraft,
|
|
"Fog density"
|
|
),
|
|
padding: readNonNegativeNumberDraft(
|
|
boxVolumeFogPaddingDraft,
|
|
"Fog padding"
|
|
)
|
|
}
|
|
};
|
|
case "light":
|
|
return currentVolume.mode === "light"
|
|
? currentVolume
|
|
: {
|
|
mode: "light",
|
|
light: {
|
|
colorHex: boxVolumeLightColorDraft,
|
|
intensity: readNonNegativeNumberDraft(
|
|
boxVolumeLightIntensityDraft,
|
|
"Light intensity"
|
|
),
|
|
padding: readNonNegativeNumberDraft(
|
|
boxVolumeLightPaddingDraft,
|
|
"Light padding"
|
|
),
|
|
falloff: boxVolumeLightFalloffDraft
|
|
}
|
|
};
|
|
}
|
|
},
|
|
`Set box volume mode to ${mode}`,
|
|
`Set selected whitebox box volume mode to ${formatBoxVolumeModeLabel(mode)}.`
|
|
);
|
|
};
|
|
|
|
const resolveDraftBoxWaterSettings = (
|
|
overrides: {
|
|
colorHex?: string;
|
|
surfaceOpacity?: number;
|
|
waveStrength?: number;
|
|
foamContactLimit?: number;
|
|
surfaceDisplacementEnabled?: boolean;
|
|
} = {}
|
|
) => ({
|
|
colorHex: overrides.colorHex ?? boxVolumeWaterColorDraft,
|
|
surfaceOpacity:
|
|
overrides.surfaceOpacity ??
|
|
readNonNegativeNumberDraft(
|
|
boxVolumeWaterSurfaceOpacityDraft,
|
|
"Water surface opacity"
|
|
),
|
|
waveStrength:
|
|
overrides.waveStrength ??
|
|
readNonNegativeNumberDraft(
|
|
boxVolumeWaterWaveStrengthDraft,
|
|
"Water wave strength"
|
|
),
|
|
foamContactLimit:
|
|
overrides.foamContactLimit ??
|
|
readWaterFoamContactLimitDraft(boxVolumeWaterFoamContactLimitDraft),
|
|
surfaceDisplacementEnabled:
|
|
overrides.surfaceDisplacementEnabled ??
|
|
boxVolumeWaterSurfaceDisplacementEnabledDraft
|
|
});
|
|
|
|
const applyBoxWaterSettings = (
|
|
overrides: {
|
|
colorHex?: string;
|
|
surfaceOpacity?: number;
|
|
waveStrength?: number;
|
|
foamContactLimit?: number;
|
|
surfaceDisplacementEnabled?: boolean;
|
|
} = {}
|
|
) => {
|
|
if (selectedBrush === null || selectedBrush.volume.mode !== "water") {
|
|
return;
|
|
}
|
|
|
|
applyBoxVolumeSettings(
|
|
() => ({
|
|
mode: "water",
|
|
water: resolveDraftBoxWaterSettings(overrides)
|
|
}),
|
|
"Set box water settings",
|
|
"Updated selected whitebox water settings."
|
|
);
|
|
};
|
|
|
|
const applyBoxWaterColorDraft = (colorHex: string) => {
|
|
if (selectedBrush === null || selectedBrush.volume.mode !== "water") {
|
|
return;
|
|
}
|
|
|
|
applyBoxVolumeSettings(
|
|
() => ({
|
|
mode: "water",
|
|
water: resolveDraftBoxWaterSettings({ colorHex })
|
|
}),
|
|
"Set box water color",
|
|
"Updated selected whitebox water color."
|
|
);
|
|
};
|
|
|
|
const applyBoxFogSettings = () => {
|
|
if (selectedBrush === null || selectedBrush.volume.mode !== "fog") {
|
|
return;
|
|
}
|
|
|
|
applyBoxVolumeSettings(
|
|
() => ({
|
|
mode: "fog",
|
|
fog: {
|
|
colorHex: boxVolumeFogColorDraft,
|
|
density: readNonNegativeNumberDraft(
|
|
boxVolumeFogDensityDraft,
|
|
"Fog density"
|
|
),
|
|
padding: readNonNegativeNumberDraft(
|
|
boxVolumeFogPaddingDraft,
|
|
"Fog padding"
|
|
)
|
|
}
|
|
}),
|
|
"Set box fog settings",
|
|
"Updated selected whitebox fog settings."
|
|
);
|
|
};
|
|
|
|
const applyBoxFogColorDraft = (colorHex: string) => {
|
|
if (selectedBrush === null || selectedBrush.volume.mode !== "fog") {
|
|
return;
|
|
}
|
|
|
|
applyBoxVolumeSettings(
|
|
() => ({
|
|
mode: "fog",
|
|
fog: {
|
|
colorHex,
|
|
density: readNonNegativeNumberDraft(
|
|
boxVolumeFogDensityDraft,
|
|
"Fog density"
|
|
),
|
|
padding: readNonNegativeNumberDraft(
|
|
boxVolumeFogPaddingDraft,
|
|
"Fog padding"
|
|
)
|
|
}
|
|
}),
|
|
"Set box fog color",
|
|
"Updated selected whitebox fog color."
|
|
);
|
|
};
|
|
|
|
const resolveDraftBoxLightSettings = (
|
|
overrides: {
|
|
colorHex?: string;
|
|
intensity?: number;
|
|
padding?: number;
|
|
falloff?: BoxBrushLightFalloffMode;
|
|
} = {}
|
|
) => ({
|
|
colorHex: overrides.colorHex ?? boxVolumeLightColorDraft,
|
|
intensity:
|
|
overrides.intensity ??
|
|
readNonNegativeNumberDraft(
|
|
boxVolumeLightIntensityDraft,
|
|
"Light intensity"
|
|
),
|
|
padding:
|
|
overrides.padding ??
|
|
readNonNegativeNumberDraft(boxVolumeLightPaddingDraft, "Light padding"),
|
|
falloff: overrides.falloff ?? boxVolumeLightFalloffDraft
|
|
});
|
|
|
|
const applyBoxLightSettings = (
|
|
overrides: {
|
|
colorHex?: string;
|
|
intensity?: number;
|
|
padding?: number;
|
|
falloff?: BoxBrushLightFalloffMode;
|
|
} = {}
|
|
) => {
|
|
if (selectedBrush === null || selectedBrush.volume.mode !== "light") {
|
|
return;
|
|
}
|
|
|
|
applyBoxVolumeSettings(
|
|
() => ({
|
|
mode: "light",
|
|
light: resolveDraftBoxLightSettings(overrides)
|
|
}),
|
|
"Set box light settings",
|
|
"Updated selected whitebox light settings."
|
|
);
|
|
};
|
|
|
|
const applyBoxLightColorDraft = (colorHex: string) => {
|
|
if (selectedBrush === null || selectedBrush.volume.mode !== "light") {
|
|
return;
|
|
}
|
|
|
|
applyBoxVolumeSettings(
|
|
() => ({
|
|
mode: "light",
|
|
light: resolveDraftBoxLightSettings({ colorHex })
|
|
}),
|
|
"Set box light color",
|
|
"Updated selected whitebox light color."
|
|
);
|
|
};
|
|
|
|
const commitEntityChange = (
|
|
currentEntity: EntityInstance,
|
|
nextEntity: EntityInstance,
|
|
successMessage: string
|
|
) => {
|
|
if (areEntityInstancesEqual(currentEntity, nextEntity)) {
|
|
return;
|
|
}
|
|
|
|
store.executeCommand(
|
|
createUpsertEntityCommand({
|
|
entity: nextEntity,
|
|
label: `Update ${getEntityKindLabel(nextEntity.kind).toLowerCase()}`
|
|
})
|
|
);
|
|
setStatusMessage(successMessage);
|
|
};
|
|
|
|
const beginEntityCreation = (
|
|
kind: EntityKind,
|
|
options: {
|
|
audioAssetId?: string | null;
|
|
modelAssetId?: string | null;
|
|
} = {}
|
|
) => {
|
|
beginCreation(
|
|
{
|
|
kind: "create",
|
|
sourcePanelId: activePanelId,
|
|
target: {
|
|
kind: "entity",
|
|
entityKind: kind,
|
|
audioAssetId: options.audioAssetId ?? null,
|
|
modelAssetId: options.modelAssetId ?? null
|
|
},
|
|
center: null
|
|
},
|
|
`Previewing ${getEntityKindLabel(kind)}. Click in the viewport to place it.`
|
|
);
|
|
};
|
|
|
|
const beginModelInstanceCreation = (assetId: string) => {
|
|
const asset = editorState.document.assets[assetId];
|
|
|
|
if (asset === undefined || asset.kind !== "model") {
|
|
setStatusMessage("Select a model asset before placing a model instance.");
|
|
return;
|
|
}
|
|
|
|
beginCreation(
|
|
{
|
|
kind: "create",
|
|
sourcePanelId: activePanelId,
|
|
target: {
|
|
kind: "model-instance",
|
|
assetId: asset.id
|
|
},
|
|
center: null
|
|
},
|
|
`Previewing ${asset.sourceName}. Click in the viewport to place it.`
|
|
);
|
|
};
|
|
|
|
const buildRuntimeSceneForProjectScene = (
|
|
sceneId: string,
|
|
options: { sceneEntryId?: string | null } = {}
|
|
): RuntimeSceneDefinition => {
|
|
const sceneDocument = createSceneDocumentFromProject(
|
|
editorState.projectDocument,
|
|
sceneId
|
|
);
|
|
|
|
return buildRuntimeSceneFromDocument(sceneDocument, {
|
|
loadedModelAssets,
|
|
runtimeClock: runtimeGlobalState.clock,
|
|
sceneEntryId: options.sceneEntryId
|
|
});
|
|
};
|
|
|
|
const applyRuntimeSceneSession = (
|
|
sceneId: string,
|
|
nextRuntimeScene: RuntimeSceneDefinition
|
|
) => {
|
|
const projectScene = editorState.projectDocument.scenes[sceneId];
|
|
|
|
if (projectScene === undefined) {
|
|
throw new Error(`Project scene ${sceneId} does not exist.`);
|
|
}
|
|
|
|
setRuntimeScene(nextRuntimeScene);
|
|
setRuntimeSceneId(projectScene.id);
|
|
setRuntimeSceneName(projectScene.name);
|
|
setRuntimeSceneLoadingScreen(
|
|
cloneSceneLoadingScreenSettings(projectScene.loadingScreen)
|
|
);
|
|
setActiveNavigationMode(nextRuntimeScene.navigationMode);
|
|
};
|
|
|
|
const handleRuntimeClockChange = (clock: RuntimeClockState) => {
|
|
setRuntimeGlobalState((currentState) =>
|
|
areRuntimeClockStatesEqual(currentState.clock, clock)
|
|
? currentState
|
|
: {
|
|
...currentState,
|
|
clock
|
|
}
|
|
);
|
|
};
|
|
|
|
const handlePlayEditorSimulation = () => {
|
|
editorSimulationController.play();
|
|
};
|
|
|
|
const handlePauseEditorSimulation = () => {
|
|
editorSimulationController.pause();
|
|
};
|
|
|
|
const handleResetEditorSimulation = () => {
|
|
editorSimulationController.reset();
|
|
};
|
|
|
|
const handleStepEditorSimulation = (deltaHours: number) => {
|
|
editorSimulationController.stepHours(deltaHours);
|
|
};
|
|
|
|
const handleRunnerSceneTransitionActivated = (
|
|
request: RuntimeSceneTransitionRequest
|
|
) => {
|
|
if (runtimeSceneId === null || runtimeSceneName === null) {
|
|
setRuntimeMessage("Scene transition failed: run mode is not active.");
|
|
return;
|
|
}
|
|
|
|
const sourceScene = editorState.projectDocument.scenes[runtimeSceneId];
|
|
const targetScene =
|
|
editorState.projectDocument.scenes[request.targetSceneId];
|
|
|
|
if (sourceScene === undefined) {
|
|
setRuntimeMessage(
|
|
`Scene transition failed: source scene ${runtimeSceneId} is no longer available.`
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (targetScene === undefined) {
|
|
setRuntimeMessage(
|
|
`Scene transition failed: target scene ${request.targetSceneId} does not exist.`
|
|
);
|
|
return;
|
|
}
|
|
|
|
const targetEntry = targetScene.entities[request.targetEntryEntityId];
|
|
|
|
if (targetEntry === undefined) {
|
|
setRuntimeMessage(
|
|
`Scene transition failed: target Scene Entry ${request.targetEntryEntityId} does not exist in ${targetScene.name}.`
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (targetEntry.kind !== "sceneEntry") {
|
|
setRuntimeMessage(
|
|
`Scene transition failed: target ${request.targetEntryEntityId} in ${targetScene.name} is not a Scene Entry.`
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const nextRuntimeScene = buildRuntimeSceneForProjectScene(
|
|
targetScene.id,
|
|
{
|
|
sceneEntryId: targetEntry.id
|
|
}
|
|
);
|
|
|
|
applyRuntimeSceneSession(targetScene.id, nextRuntimeScene);
|
|
setRuntimeMessage(null);
|
|
setFirstPersonTelemetry(null);
|
|
setRuntimeInteractionPrompt(null);
|
|
setRuntimeGlobalState((currentState) => ({
|
|
...currentState,
|
|
transitionCount: currentState.transitionCount + 1,
|
|
lastSceneTransition: {
|
|
fromSceneId: sourceScene.id,
|
|
fromSceneName: sourceScene.name,
|
|
toSceneId: targetScene.id,
|
|
toSceneName: targetScene.name,
|
|
sourceEntityId: request.sourceEntityId,
|
|
targetEntryEntityId: targetEntry.id
|
|
}
|
|
}));
|
|
setStatusMessage(`Loaded ${targetScene.name}.`);
|
|
} catch (error) {
|
|
const message = getErrorMessage(error);
|
|
setRuntimeMessage(`Scene transition failed: ${message}`);
|
|
setStatusMessage(`Scene transition failed: ${message}`);
|
|
}
|
|
};
|
|
|
|
const handleCommitCreation = (
|
|
creationPreview: CreationViewportToolPreview
|
|
): boolean => {
|
|
try {
|
|
if (creationPreview.target.kind === "box-brush") {
|
|
const center =
|
|
creationPreview.center === null ? undefined : creationPreview.center;
|
|
|
|
store.executeCommand(
|
|
createCreateBoxBrushCommand(
|
|
center === undefined
|
|
? {
|
|
snapToGrid: whiteboxSnapEnabled,
|
|
gridSize: whiteboxSnapStep
|
|
}
|
|
: {
|
|
center,
|
|
snapToGrid: whiteboxSnapEnabled,
|
|
gridSize: whiteboxSnapStep
|
|
}
|
|
)
|
|
);
|
|
completeCreation(
|
|
center === undefined
|
|
? whiteboxSnapEnabled
|
|
? `Created a whitebox box on the ${whiteboxSnapStep}m grid.`
|
|
: "Created a whitebox box."
|
|
: whiteboxSnapEnabled
|
|
? `Created a whitebox box at snapped center ${formatVec3(center)}.`
|
|
: `Created a whitebox box at ${formatVec3(center)}.`
|
|
);
|
|
return true;
|
|
}
|
|
|
|
if (creationPreview.target.kind === "wedge-brush") {
|
|
const center =
|
|
creationPreview.center === null ? undefined : creationPreview.center;
|
|
|
|
store.executeCommand(
|
|
createCreateWedgeBrushCommand(
|
|
center === undefined
|
|
? {
|
|
snapToGrid: whiteboxSnapEnabled,
|
|
gridSize: whiteboxSnapStep
|
|
}
|
|
: {
|
|
center,
|
|
snapToGrid: whiteboxSnapEnabled,
|
|
gridSize: whiteboxSnapStep
|
|
}
|
|
)
|
|
);
|
|
completeCreation(
|
|
center === undefined
|
|
? whiteboxSnapEnabled
|
|
? `Created a whitebox wedge on the ${whiteboxSnapStep}m grid.`
|
|
: "Created a whitebox wedge."
|
|
: whiteboxSnapEnabled
|
|
? `Created a whitebox wedge at snapped center ${formatVec3(center)}.`
|
|
: `Created a whitebox wedge at ${formatVec3(center)}.`
|
|
);
|
|
return true;
|
|
}
|
|
|
|
if (creationPreview.target.kind === "cylinder-brush") {
|
|
const center =
|
|
creationPreview.center === null ? undefined : creationPreview.center;
|
|
|
|
store.executeCommand(
|
|
createCreateRadialPrismBrushCommand(
|
|
center === undefined
|
|
? {
|
|
sideCount: creationPreview.target.sideCount,
|
|
snapToGrid: whiteboxSnapEnabled,
|
|
gridSize: whiteboxSnapStep
|
|
}
|
|
: {
|
|
center,
|
|
sideCount: creationPreview.target.sideCount,
|
|
snapToGrid: whiteboxSnapEnabled,
|
|
gridSize: whiteboxSnapStep
|
|
}
|
|
)
|
|
);
|
|
completeCreation(
|
|
center === undefined
|
|
? whiteboxSnapEnabled
|
|
? `Created a whitebox cylinder on the ${whiteboxSnapStep}m grid.`
|
|
: "Created a whitebox cylinder."
|
|
: whiteboxSnapEnabled
|
|
? `Created a whitebox cylinder at snapped center ${formatVec3(center)}.`
|
|
: `Created a whitebox cylinder at ${formatVec3(center)}.`
|
|
);
|
|
return true;
|
|
}
|
|
|
|
if (creationPreview.target.kind === "cone-brush") {
|
|
const center =
|
|
creationPreview.center === null ? undefined : creationPreview.center;
|
|
|
|
store.executeCommand(
|
|
createCreateConeBrushCommand(
|
|
center === undefined
|
|
? {
|
|
sideCount: creationPreview.target.sideCount,
|
|
snapToGrid: whiteboxSnapEnabled,
|
|
gridSize: whiteboxSnapStep
|
|
}
|
|
: {
|
|
center,
|
|
sideCount: creationPreview.target.sideCount,
|
|
snapToGrid: whiteboxSnapEnabled,
|
|
gridSize: whiteboxSnapStep
|
|
}
|
|
)
|
|
);
|
|
completeCreation(
|
|
center === undefined
|
|
? whiteboxSnapEnabled
|
|
? `Created a whitebox cone on the ${whiteboxSnapStep}m grid.`
|
|
: "Created a whitebox cone."
|
|
: whiteboxSnapEnabled
|
|
? `Created a whitebox cone at snapped center ${formatVec3(center)}.`
|
|
: `Created a whitebox cone at ${formatVec3(center)}.`
|
|
);
|
|
return true;
|
|
}
|
|
|
|
if (creationPreview.target.kind === "torus-brush") {
|
|
const center =
|
|
creationPreview.center === null ? undefined : creationPreview.center;
|
|
|
|
store.executeCommand(
|
|
createCreateTorusBrushCommand(
|
|
center === undefined
|
|
? {
|
|
majorSegmentCount: creationPreview.target.majorSegmentCount,
|
|
tubeSegmentCount: creationPreview.target.tubeSegmentCount,
|
|
snapToGrid: whiteboxSnapEnabled,
|
|
gridSize: whiteboxSnapStep
|
|
}
|
|
: {
|
|
center,
|
|
majorSegmentCount: creationPreview.target.majorSegmentCount,
|
|
tubeSegmentCount: creationPreview.target.tubeSegmentCount,
|
|
snapToGrid: whiteboxSnapEnabled,
|
|
gridSize: whiteboxSnapStep
|
|
}
|
|
)
|
|
);
|
|
completeCreation(
|
|
center === undefined
|
|
? whiteboxSnapEnabled
|
|
? `Created a whitebox torus on the ${whiteboxSnapStep}m grid.`
|
|
: "Created a whitebox torus."
|
|
: whiteboxSnapEnabled
|
|
? `Created a whitebox torus at snapped center ${formatVec3(center)}.`
|
|
: `Created a whitebox torus at ${formatVec3(center)}.`
|
|
);
|
|
return true;
|
|
}
|
|
|
|
if (creationPreview.target.kind === "model-instance") {
|
|
const asset =
|
|
editorState.document.assets[creationPreview.target.assetId];
|
|
|
|
if (asset === undefined || asset.kind !== "model") {
|
|
setStatusMessage(
|
|
"Select a model asset before placing a model instance."
|
|
);
|
|
return false;
|
|
}
|
|
|
|
const nextModelInstance = createModelInstance({
|
|
assetId: asset.id,
|
|
position:
|
|
creationPreview.center === null
|
|
? createModelInstancePlacementPosition(asset, null)
|
|
: creationPreview.center,
|
|
rotationDegrees: DEFAULT_MODEL_INSTANCE_ROTATION_DEGREES,
|
|
scale: DEFAULT_MODEL_INSTANCE_SCALE
|
|
});
|
|
|
|
store.executeCommand(
|
|
createUpsertModelInstanceCommand({
|
|
modelInstance: nextModelInstance,
|
|
label: `Place ${asset.sourceName}`
|
|
})
|
|
);
|
|
completeCreation(`Placed ${asset.sourceName}.`);
|
|
return true;
|
|
}
|
|
|
|
const position = creationPreview.center ?? DEFAULT_ENTITY_POSITION;
|
|
|
|
switch (creationPreview.target.entityKind) {
|
|
case "pointLight":
|
|
store.executeCommand(
|
|
createUpsertEntityCommand({
|
|
entity: createPointLightEntity({
|
|
position
|
|
}),
|
|
label: "Place point light"
|
|
})
|
|
);
|
|
completeCreation("Placed Point Light.");
|
|
return true;
|
|
case "spotLight":
|
|
store.executeCommand(
|
|
createUpsertEntityCommand({
|
|
entity: createSpotLightEntity({
|
|
position
|
|
}),
|
|
label: "Place spot light"
|
|
})
|
|
);
|
|
completeCreation("Placed Spot Light.");
|
|
return true;
|
|
case "cameraRig":
|
|
store.executeCommand(
|
|
createUpsertEntityCommand({
|
|
entity: createCameraRigEntity({
|
|
position
|
|
}),
|
|
label: "Place camera rig"
|
|
})
|
|
);
|
|
completeCreation("Placed Camera Rig.");
|
|
return true;
|
|
case "playerStart":
|
|
store.executeCommand(
|
|
createUpsertEntityCommand({
|
|
entity: createPlayerStartEntity({
|
|
position
|
|
}),
|
|
label: "Place player start"
|
|
})
|
|
);
|
|
completeCreation("Placed Player Start.");
|
|
return true;
|
|
case "sceneEntry":
|
|
store.executeCommand(
|
|
createUpsertEntityCommand({
|
|
entity: createSceneEntryEntity({
|
|
position
|
|
}),
|
|
label: "Place scene entry"
|
|
})
|
|
);
|
|
completeCreation("Placed Scene Entry.");
|
|
return true;
|
|
case "npc": {
|
|
const placedModelAssetId =
|
|
creationPreview.target.modelAssetId ?? null;
|
|
|
|
store.executeCommand(
|
|
createUpsertEntityCommand({
|
|
entity: createNpcEntity({
|
|
position,
|
|
modelAssetId: placedModelAssetId
|
|
}),
|
|
label: "Place npc"
|
|
})
|
|
);
|
|
completeCreation(
|
|
placedModelAssetId === null
|
|
? "Placed NPC."
|
|
: `Placed NPC using ${editorState.document.assets[placedModelAssetId]?.sourceName ?? "the authored model asset"}.`
|
|
);
|
|
return true;
|
|
}
|
|
case "soundEmitter": {
|
|
const placedAudioAssetId =
|
|
creationPreview.target.audioAssetId ??
|
|
audioAssetList[0]?.id ??
|
|
null;
|
|
|
|
store.executeCommand(
|
|
createUpsertEntityCommand({
|
|
entity: createSoundEmitterEntity({
|
|
position,
|
|
audioAssetId: placedAudioAssetId
|
|
}),
|
|
label: "Place sound emitter"
|
|
})
|
|
);
|
|
completeCreation(
|
|
placedAudioAssetId === null
|
|
? "Placed Sound Emitter."
|
|
: `Placed Sound Emitter using ${editorState.document.assets[placedAudioAssetId]?.sourceName ?? "the authored audio asset"}.`
|
|
);
|
|
return true;
|
|
}
|
|
case "triggerVolume":
|
|
store.executeCommand(
|
|
createUpsertEntityCommand({
|
|
entity: createTriggerVolumeEntity({
|
|
position
|
|
}),
|
|
label: "Place trigger volume"
|
|
})
|
|
);
|
|
completeCreation("Placed Trigger Volume.");
|
|
return true;
|
|
case "teleportTarget":
|
|
store.executeCommand(
|
|
createUpsertEntityCommand({
|
|
entity: createTeleportTargetEntity({
|
|
position
|
|
}),
|
|
label: "Place teleport target"
|
|
})
|
|
);
|
|
completeCreation("Placed Teleport Target.");
|
|
return true;
|
|
case "interactable":
|
|
store.executeCommand(
|
|
createUpsertEntityCommand({
|
|
entity: createInteractableEntity({
|
|
position
|
|
}),
|
|
label: "Place interactable"
|
|
})
|
|
);
|
|
completeCreation("Placed Interactable.");
|
|
return true;
|
|
}
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
const handleCreatePath = () => {
|
|
try {
|
|
const nextPath = createScenePath();
|
|
|
|
store.executeCommand(
|
|
createUpsertPathCommand({
|
|
path: nextPath,
|
|
label: "Create path"
|
|
})
|
|
);
|
|
requestViewportFocus({
|
|
kind: "paths",
|
|
ids: [nextPath.id]
|
|
});
|
|
setStatusMessage("Created Path and framed it in the viewport.");
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const handleCreateTerrain = () => {
|
|
try {
|
|
const nextTerrain = createTerrain();
|
|
|
|
store.executeCommand(
|
|
createUpsertTerrainCommand({
|
|
terrain: nextTerrain,
|
|
label: "Create terrain"
|
|
})
|
|
);
|
|
requestViewportFocus({
|
|
kind: "terrains",
|
|
ids: [nextTerrain.id]
|
|
});
|
|
setStatusMessage("Created Terrain and framed it in the viewport.");
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const handleArmTerrainBrushTool = (tool: TerrainBrushTool) => {
|
|
if (selectedTerrain === null) {
|
|
return;
|
|
}
|
|
|
|
if (armedTerrainBrushTool === tool) {
|
|
setArmedTerrainBrushTool(null);
|
|
setStatusMessage(
|
|
`Disarmed terrain brush editing for ${getTerrainLabelById(selectedTerrain.id, terrainList)}.`
|
|
);
|
|
return;
|
|
}
|
|
|
|
setArmedTerrainBrushTool(tool);
|
|
const paintLayerLabel =
|
|
tool === "paint"
|
|
? ` ${getTerrainLayerLabel(
|
|
clampTerrainPaintLayerIndex(activeTerrainPaintLayerIndex)
|
|
).toLowerCase()}`
|
|
: "";
|
|
setStatusMessage(
|
|
`Armed ${getTerrainBrushToolLabel(tool)} terrain brush${paintLayerLabel} for ${getTerrainLabelById(selectedTerrain.id, terrainList)}. Drag in the viewport to edit the selected terrain.`
|
|
);
|
|
};
|
|
|
|
const handleTerrainBrushRadiusChange = (value: string) => {
|
|
setTerrainBrushSettings((currentSettings) => ({
|
|
...currentSettings,
|
|
radius: clampTerrainBrushRadius(Number(value))
|
|
}));
|
|
};
|
|
|
|
const handleTerrainBrushStrengthChange = (value: string) => {
|
|
setTerrainBrushSettings((currentSettings) => ({
|
|
...currentSettings,
|
|
strength: clampTerrainBrushStrength(Number(value))
|
|
}));
|
|
};
|
|
|
|
const handleTerrainBrushFalloffChange = (value: string) => {
|
|
setTerrainBrushSettings((currentSettings) => ({
|
|
...currentSettings,
|
|
falloff: clampTerrainBrushFalloff(Number(value))
|
|
}));
|
|
};
|
|
|
|
const handleTerrainPaintLayerChange = (value: string) => {
|
|
setActiveTerrainPaintLayerIndex(clampTerrainPaintLayerIndex(Number(value)));
|
|
};
|
|
|
|
const handleTerrainLayerMaterialChange = (
|
|
layerIndex: number,
|
|
materialId: string
|
|
) => {
|
|
if (selectedTerrain === null) {
|
|
return;
|
|
}
|
|
|
|
const nextMaterialId = materialId === "" ? null : materialId;
|
|
const currentMaterialId =
|
|
selectedTerrain.layers[layerIndex]?.materialId ?? null;
|
|
|
|
if (currentMaterialId === nextMaterialId) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const nextTerrain = createTerrain({
|
|
...selectedTerrain,
|
|
layers: selectedTerrain.layers.map((layer, currentLayerIndex) => ({
|
|
materialId:
|
|
currentLayerIndex === layerIndex ? nextMaterialId : layer.materialId
|
|
}))
|
|
});
|
|
|
|
store.executeCommand(
|
|
createUpsertTerrainCommand({
|
|
terrain: nextTerrain,
|
|
label: `Set ${getTerrainLayerLabel(layerIndex).toLowerCase()} material`
|
|
})
|
|
);
|
|
|
|
setStatusMessage(
|
|
`${getTerrainLayerLabel(layerIndex)} now uses ${
|
|
nextMaterialId === null
|
|
? "no assigned material"
|
|
: (editorState.document.materials[nextMaterialId]?.name ??
|
|
nextMaterialId)
|
|
}.`
|
|
);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const handleCommitTerrainBrushStroke = (
|
|
commit: TerrainBrushStrokeCommit
|
|
): boolean => {
|
|
try {
|
|
store.executeCommand(
|
|
createUpsertTerrainCommand({
|
|
terrain: commit.terrain,
|
|
label: commit.commandLabel
|
|
})
|
|
);
|
|
setStatusMessage(
|
|
`${commit.commandLabel} on ${getTerrainLabelById(commit.terrain.id, terrainList)}.`
|
|
);
|
|
return true;
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const commitPathChange = (
|
|
currentPath: ScenePath,
|
|
nextPath: ScenePath,
|
|
successMessage: string
|
|
) => {
|
|
if (areScenePathsEqual(currentPath, nextPath)) {
|
|
return;
|
|
}
|
|
|
|
store.executeCommand(
|
|
createUpsertPathCommand({
|
|
path: nextPath,
|
|
label: "Update path"
|
|
})
|
|
);
|
|
setStatusMessage(successMessage);
|
|
};
|
|
|
|
const applyPathChange = () => {
|
|
if (selectedPath === null) {
|
|
setStatusMessage("Select a path before editing it.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const nextPath = createScenePath({
|
|
id: selectedPath.id,
|
|
name: selectedPath.name,
|
|
visible: selectedPath.visible,
|
|
enabled: selectedPath.enabled,
|
|
loop: selectedPath.loop,
|
|
points: selectedPath.points.map((point, index) => ({
|
|
id: point.id,
|
|
position: readVec3Draft(
|
|
pathPointDrafts[index] ?? createVec3Draft(point.position),
|
|
`Path point ${index + 1}`
|
|
)
|
|
}))
|
|
});
|
|
|
|
commitPathChange(selectedPath, nextPath, "Updated Path points.");
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const handlePathLoopChange = (loop: boolean) => {
|
|
if (selectedPath === null) {
|
|
setStatusMessage("Select a path before changing its loop state.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const nextPath = createScenePath({
|
|
...selectedPath,
|
|
loop
|
|
});
|
|
|
|
commitPathChange(
|
|
selectedPath,
|
|
nextPath,
|
|
loop ? "Enabled Path looping." : "Disabled Path looping."
|
|
);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const handlePathPointDraftChange = (
|
|
pointIndex: number,
|
|
axis: keyof Vec3Draft,
|
|
value: string
|
|
) => {
|
|
setPathPointDrafts((currentDrafts) =>
|
|
currentDrafts.map((draft, index) =>
|
|
index === pointIndex
|
|
? {
|
|
...draft,
|
|
[axis]: value
|
|
}
|
|
: draft
|
|
)
|
|
);
|
|
};
|
|
|
|
const handleAddPathPoint = () => {
|
|
if (selectedPath === null) {
|
|
setStatusMessage("Select a path before adding a point.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const nextPoint = createAppendedScenePathPoint(selectedPath);
|
|
|
|
store.executeCommand(
|
|
createAddPathPointCommand({
|
|
pathId: selectedPath.id,
|
|
point: nextPoint,
|
|
label: "Add path point"
|
|
})
|
|
);
|
|
requestViewportFocus(
|
|
{
|
|
kind: "pathPoint",
|
|
pathId: selectedPath.id,
|
|
pointId: nextPoint.id
|
|
},
|
|
`Appended path point ${selectedPath.points.length + 1} to ${getPathLabelById(selectedPath.id, pathList)}.`
|
|
);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const handleDeletePathPoint = (pointId: string) => {
|
|
if (selectedPath === null) {
|
|
setStatusMessage("Select a path before removing a point.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
store.executeCommand(
|
|
createDeletePathPointCommand({
|
|
pathId: selectedPath.id,
|
|
pointId,
|
|
label: "Delete path point"
|
|
})
|
|
);
|
|
setStatusMessage("Removed the selected path point.");
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const commitModelInstanceChange = (
|
|
currentModelInstance: ModelInstance,
|
|
nextModelInstance: ModelInstance,
|
|
successMessage: string
|
|
) => {
|
|
if (areModelInstancesEqual(currentModelInstance, nextModelInstance)) {
|
|
return;
|
|
}
|
|
|
|
store.executeCommand(
|
|
createUpsertModelInstanceCommand({
|
|
modelInstance: nextModelInstance,
|
|
label: `Update ${getModelInstanceDisplayLabelById(currentModelInstance.id, editorState.document.modelInstances, editorState.document.assets).toLowerCase()}`
|
|
})
|
|
);
|
|
setStatusMessage(successMessage);
|
|
};
|
|
|
|
const commitTerrainChange = (
|
|
currentTerrain: Terrain,
|
|
nextTerrain: Terrain,
|
|
commandLabel: string,
|
|
successMessage: string
|
|
): boolean => {
|
|
if (areTerrainsEqual(currentTerrain, nextTerrain)) {
|
|
return false;
|
|
}
|
|
|
|
store.executeCommand(
|
|
createUpsertTerrainCommand({
|
|
terrain: nextTerrain,
|
|
label: commandLabel
|
|
})
|
|
);
|
|
setStatusMessage(successMessage);
|
|
return true;
|
|
};
|
|
|
|
const applyModelInstanceChange = () => {
|
|
if (selectedModelInstance === null) {
|
|
setStatusMessage("Select a model instance before editing it.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const nextModelInstance = createModelInstance({
|
|
id: selectedModelInstance.id,
|
|
assetId: selectedModelInstance.assetId,
|
|
name: selectedModelInstance.name,
|
|
collision: selectedModelInstance.collision,
|
|
position: readVec3Draft(modelPositionDraft, "Model instance position"),
|
|
rotationDegrees: readVec3Draft(
|
|
modelRotationDraft,
|
|
"Model instance rotation"
|
|
),
|
|
scale: readPositiveVec3Draft(modelScaleDraft, "Model instance scale"),
|
|
animationClipName: selectedModelInstance.animationClipName,
|
|
animationAutoplay: selectedModelInstance.animationAutoplay
|
|
});
|
|
|
|
commitModelInstanceChange(
|
|
selectedModelInstance,
|
|
nextModelInstance,
|
|
"Updated model instance."
|
|
);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const applyTerrainGridChange = () => {
|
|
if (selectedTerrain === null) {
|
|
setStatusMessage("Select a terrain before resizing its grid.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const nextTerrain = resizeTerrainGrid(selectedTerrain, {
|
|
sampleCountX: Number(terrainSampleCountXDraft),
|
|
sampleCountZ: Number(terrainSampleCountZDraft),
|
|
cellSize: Number(terrainCellSizeDraft)
|
|
});
|
|
const terrainLabel = getTerrainLabelById(selectedTerrain.id, terrainList);
|
|
|
|
commitTerrainChange(
|
|
selectedTerrain,
|
|
nextTerrain,
|
|
"Resize terrain grid",
|
|
`Resampled ${terrainLabel} to ${nextTerrain.sampleCountX} x ${nextTerrain.sampleCountZ} samples with ${nextTerrain.cellSize}m square cells.`
|
|
);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const handleTerrainCollisionEnabledChange = (enabled: boolean) => {
|
|
if (
|
|
selectedTerrain === null ||
|
|
selectedTerrain.collisionEnabled === enabled
|
|
) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const nextTerrain = createTerrain({
|
|
...selectedTerrain,
|
|
collisionEnabled: enabled
|
|
});
|
|
const terrainLabel = getTerrainLabelById(selectedTerrain.id, terrainList);
|
|
|
|
commitTerrainChange(
|
|
selectedTerrain,
|
|
nextTerrain,
|
|
enabled ? "Enable terrain collision" : "Disable terrain collision",
|
|
enabled
|
|
? `${terrainLabel} now participates in runner collision as a heightfield.`
|
|
: `${terrainLabel} no longer contributes runner collision.`
|
|
);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const setPlayerStartMovementTemplateEditorDraft = (
|
|
template: PlayerStartMovementTemplate
|
|
) => {
|
|
setPlayerStartMovementTemplateDraft(template);
|
|
setPlayerStartMovementTemplateNumberDraft(
|
|
createPlayerStartMovementTemplateNumberDraft(template)
|
|
);
|
|
};
|
|
|
|
const buildPlayerStartMovementTemplateFromDraft = (
|
|
overrides: {
|
|
kind?: PlayerStartMovementTemplate["kind"];
|
|
capabilities?: Partial<PlayerStartMovementTemplate["capabilities"]>;
|
|
jump?: Partial<PlayerStartMovementTemplate["jump"]>;
|
|
sprint?: Partial<PlayerStartMovementTemplate["sprint"]>;
|
|
crouch?: Partial<PlayerStartMovementTemplate["crouch"]>;
|
|
} = {}
|
|
): PlayerStartMovementTemplate => {
|
|
const rawTemplate = createPlayerStartMovementTemplate({
|
|
kind: overrides.kind ?? playerStartMovementTemplateDraft.kind,
|
|
moveSpeed: readPositiveNumberDraft(
|
|
playerStartMovementTemplateNumberDraft.moveSpeed,
|
|
"Player Start move speed"
|
|
),
|
|
maxSpeed: readNonNegativeNumberDraft(
|
|
playerStartMovementTemplateNumberDraft.maxSpeed,
|
|
"Player Start max speed"
|
|
),
|
|
maxStepHeight: readNonNegativeNumberDraft(
|
|
playerStartMovementTemplateNumberDraft.maxStepHeight,
|
|
"Player Start max step height"
|
|
),
|
|
capabilities: {
|
|
...playerStartMovementTemplateDraft.capabilities,
|
|
...overrides.capabilities
|
|
},
|
|
jump: {
|
|
...playerStartMovementTemplateDraft.jump,
|
|
...overrides.jump,
|
|
speed: readPositiveNumberDraft(
|
|
playerStartMovementTemplateNumberDraft.jumpSpeed,
|
|
"Player Start jump speed"
|
|
),
|
|
bufferMs: readNonNegativeNumberDraft(
|
|
playerStartMovementTemplateNumberDraft.jumpBufferMs,
|
|
"Player Start jump buffer"
|
|
),
|
|
coyoteTimeMs: readNonNegativeNumberDraft(
|
|
playerStartMovementTemplateNumberDraft.coyoteTimeMs,
|
|
"Player Start coyote time"
|
|
),
|
|
maxHoldMs: readPositiveNumberDraft(
|
|
playerStartMovementTemplateNumberDraft.variableJumpMaxHoldMs,
|
|
"Player Start variable jump max hold"
|
|
),
|
|
bunnyHopBoost: readNonNegativeNumberDraft(
|
|
playerStartMovementTemplateNumberDraft.bunnyHopBoost,
|
|
"Player Start bunny hop boost"
|
|
)
|
|
},
|
|
sprint: {
|
|
...playerStartMovementTemplateDraft.sprint,
|
|
...overrides.sprint,
|
|
speedMultiplier: readPositiveNumberDraft(
|
|
playerStartMovementTemplateNumberDraft.sprintSpeedMultiplier,
|
|
"Player Start sprint speed multiplier"
|
|
)
|
|
},
|
|
crouch: {
|
|
...playerStartMovementTemplateDraft.crouch,
|
|
...overrides.crouch,
|
|
speedMultiplier: readPositiveNumberDraft(
|
|
playerStartMovementTemplateNumberDraft.crouchSpeedMultiplier,
|
|
"Player Start crouch speed multiplier"
|
|
)
|
|
}
|
|
});
|
|
|
|
return createPlayerStartMovementTemplate({
|
|
...rawTemplate,
|
|
kind: overrides.kind ?? inferPlayerStartMovementTemplateKind(rawTemplate)
|
|
});
|
|
};
|
|
|
|
const commitPlayerStartMovementTemplateDraft = (
|
|
overrides: {
|
|
kind?: PlayerStartMovementTemplate["kind"];
|
|
capabilities?: Partial<PlayerStartMovementTemplate["capabilities"]>;
|
|
jump?: Partial<PlayerStartMovementTemplate["jump"]>;
|
|
sprint?: Partial<PlayerStartMovementTemplate["sprint"]>;
|
|
crouch?: Partial<PlayerStartMovementTemplate["crouch"]>;
|
|
} = {},
|
|
options: {
|
|
schedule?: boolean;
|
|
} = {}
|
|
) => {
|
|
try {
|
|
const nextTemplate = buildPlayerStartMovementTemplateFromDraft(overrides);
|
|
setPlayerStartMovementTemplateDraft(nextTemplate);
|
|
|
|
if (options.schedule === true) {
|
|
scheduleDraftCommit(() =>
|
|
applyPlayerStartChange({
|
|
movementTemplate: nextTemplate
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
applyPlayerStartChange({
|
|
movementTemplate: nextTemplate
|
|
});
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const applyCameraRigChange = (
|
|
overrides: {
|
|
rigType?: CameraRigType;
|
|
pathId?: string;
|
|
railPlacementMode?: CameraRigRailPlacementMode;
|
|
defaultActive?: boolean;
|
|
targetKind?: CameraRigTargetKind;
|
|
transitionMode?: CameraRigTransitionMode;
|
|
lookAroundEnabled?: boolean;
|
|
} = {}
|
|
) => {
|
|
if (selectedCameraRig === null) {
|
|
setStatusMessage("Select a Camera Rig before editing it.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const rigType = overrides.rigType ?? cameraRigRigTypeDraft;
|
|
const pathId =
|
|
(overrides.pathId ?? cameraRigPathIdDraft).trim() ||
|
|
cameraRigPathOptions[0]?.path.id ||
|
|
"";
|
|
const railPlacementMode =
|
|
overrides.railPlacementMode ?? cameraRigRailPlacementModeDraft;
|
|
const targetKind = overrides.targetKind ?? cameraRigTargetKindDraft;
|
|
const targetActorId =
|
|
cameraRigTargetActorIdDraft.trim() || cameraRigActorOptions[0] || "";
|
|
const targetEntityId =
|
|
cameraRigTargetEntityIdDraft.trim() ||
|
|
cameraRigEntityTargetOptions[0]?.entity.id ||
|
|
"";
|
|
const target =
|
|
targetKind === "player"
|
|
? createCameraRigPlayerTargetRef()
|
|
: targetKind === "actor"
|
|
? createCameraRigActorTargetRef(targetActorId)
|
|
: targetKind === "entity"
|
|
? createCameraRigEntityTargetRef(targetEntityId)
|
|
: createCameraRigWorldPointTargetRef(
|
|
readVec3Draft(
|
|
cameraRigTargetWorldPointDraft,
|
|
"Camera Rig world target"
|
|
)
|
|
);
|
|
const fixedPosition = snapVec3ToGrid(
|
|
readVec3Draft(entityPositionDraft, "Camera Rig position"),
|
|
DEFAULT_GRID_SIZE
|
|
);
|
|
const railStartProgress = readNonNegativeNumberDraft(
|
|
cameraRigRailStartProgressDraft,
|
|
"Camera Rig rail start progress"
|
|
);
|
|
const railEndProgress = readNonNegativeNumberDraft(
|
|
cameraRigRailEndProgressDraft,
|
|
"Camera Rig rail end progress"
|
|
);
|
|
|
|
if (railStartProgress > 1 || railEndProgress > 1) {
|
|
throw new Error(
|
|
"Camera Rig mapped rail progress values must remain between 0 and 1."
|
|
);
|
|
}
|
|
|
|
const nextEntity = createCameraRigEntity({
|
|
id: selectedCameraRig.id,
|
|
name: selectedCameraRig.name,
|
|
visible: selectedCameraRig.visible,
|
|
enabled: selectedCameraRig.enabled,
|
|
...(rigType === "fixed"
|
|
? {
|
|
rigType: "fixed" as const,
|
|
position: fixedPosition
|
|
}
|
|
: railPlacementMode === "mapTargetBetweenPoints"
|
|
? {
|
|
rigType: "rail" as const,
|
|
pathId,
|
|
railPlacementMode: "mapTargetBetweenPoints" as const,
|
|
trackStartPoint: readVec3Draft(
|
|
cameraRigTrackStartPointDraft,
|
|
"Camera Rig track start point"
|
|
),
|
|
trackEndPoint: readVec3Draft(
|
|
cameraRigTrackEndPointDraft,
|
|
"Camera Rig track end point"
|
|
),
|
|
railStartProgress,
|
|
railEndProgress
|
|
}
|
|
: {
|
|
rigType: "rail" as const,
|
|
pathId,
|
|
railPlacementMode: "nearestToTarget" as const
|
|
}),
|
|
priority: readNonNegativeNumberDraft(
|
|
cameraRigPriorityDraft,
|
|
"Camera Rig priority"
|
|
),
|
|
defaultActive: overrides.defaultActive ?? cameraRigDefaultActiveDraft,
|
|
target,
|
|
targetOffset: readVec3Draft(
|
|
cameraRigTargetOffsetDraft,
|
|
"Camera Rig target offset"
|
|
),
|
|
transitionMode:
|
|
overrides.transitionMode ?? cameraRigTransitionModeDraft,
|
|
transitionDurationSeconds: readNonNegativeNumberDraft(
|
|
cameraRigTransitionDurationDraft,
|
|
"Camera Rig transition duration"
|
|
),
|
|
lookAround: {
|
|
enabled:
|
|
overrides.lookAroundEnabled ?? cameraRigLookAroundEnabledDraft,
|
|
yawLimitDegrees: readNonNegativeNumberDraft(
|
|
cameraRigLookAroundYawLimitDraft,
|
|
"Camera Rig look-around yaw limit"
|
|
),
|
|
pitchLimitDegrees: readNonNegativeNumberDraft(
|
|
cameraRigLookAroundPitchLimitDraft,
|
|
"Camera Rig look-around pitch limit"
|
|
),
|
|
recenterSpeed: readNonNegativeNumberDraft(
|
|
cameraRigLookAroundRecenterSpeedDraft,
|
|
"Camera Rig look-around recenter speed"
|
|
)
|
|
}
|
|
});
|
|
|
|
commitEntityChange(selectedCameraRig, nextEntity, "Updated Camera Rig.");
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const applyPlayerStartChange = (
|
|
overrides: {
|
|
colliderMode?: PlayerStartColliderMode;
|
|
movementTemplate?: PlayerStartMovementTemplate;
|
|
navigationMode?: PlayerStartNavigationMode;
|
|
inputBindings?: PlayerStartInputBindings;
|
|
} = {}
|
|
) => {
|
|
if (selectedPlayerStart === null) {
|
|
setStatusMessage("Select a Player Start before editing it.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const snappedPosition = snapVec3ToGrid(
|
|
readVec3Draft(entityPositionDraft, "Player Start position"),
|
|
DEFAULT_GRID_SIZE
|
|
);
|
|
const yawDegrees = readYawDegreesDraft(playerStartYawDraft);
|
|
const navigationMode =
|
|
overrides.navigationMode ?? playerStartNavigationModeDraft;
|
|
const movementTemplate =
|
|
overrides.movementTemplate ?? playerStartMovementTemplateDraft;
|
|
const colliderMode =
|
|
overrides.colliderMode ?? playerStartColliderModeDraft;
|
|
const inputBindings =
|
|
overrides.inputBindings ?? playerStartInputBindingsDraft;
|
|
const interactionReachMeters = readPositiveNumberDraft(
|
|
playerStartInteractionReachDraft,
|
|
"Player Start interaction reach"
|
|
);
|
|
const interactionAngleDegrees = readFiniteNumberDraft(
|
|
playerStartInteractionAngleDraft,
|
|
"Player Start interaction angle"
|
|
);
|
|
const nextEntity = createPlayerStartEntity({
|
|
id: selectedPlayerStart.id,
|
|
name: selectedPlayerStart.name,
|
|
position: snappedPosition,
|
|
yawDegrees,
|
|
navigationMode,
|
|
interactionReachMeters,
|
|
interactionAngleDegrees,
|
|
allowLookInputTargetSwitch:
|
|
playerStartAllowLookInputTargetSwitchDraft,
|
|
targetButtonCyclesActiveTarget:
|
|
playerStartTargetButtonCyclesActiveTargetDraft,
|
|
movementTemplate,
|
|
inputBindings,
|
|
collider: {
|
|
mode: colliderMode,
|
|
eyeHeight: readPositiveNumberDraft(
|
|
playerStartEyeHeightDraft,
|
|
"Player Start eye height"
|
|
),
|
|
capsuleRadius: readPositiveNumberDraft(
|
|
playerStartCapsuleRadiusDraft,
|
|
"Player Start capsule radius"
|
|
),
|
|
capsuleHeight: readPositiveNumberDraft(
|
|
playerStartCapsuleHeightDraft,
|
|
"Player Start capsule height"
|
|
),
|
|
boxSize: readPositiveVec3Draft(
|
|
playerStartBoxSizeDraft,
|
|
"Player Start box size"
|
|
)
|
|
}
|
|
});
|
|
|
|
commitEntityChange(
|
|
selectedPlayerStart,
|
|
nextEntity,
|
|
"Updated Player Start."
|
|
);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const beginPlayerStartKeyboardCapture = (action: PlayerStartInputAction) => {
|
|
if (playerStartKeyboardCaptureAction === action) {
|
|
setPlayerStartKeyboardCaptureAction(null);
|
|
setStatusMessage("Cancelled Player Start key capture.");
|
|
return;
|
|
}
|
|
|
|
setPlayerStartKeyboardCaptureAction(action);
|
|
setStatusMessage(
|
|
action === "clearTarget"
|
|
? `Press any key or mouse button for ${getPlayerStartInputActionLabel(action)}. Click the binding button again to cancel.`
|
|
: `Press any key or mouse button for ${getPlayerStartInputActionLabel(action)}. Press Escape to cancel.`
|
|
);
|
|
};
|
|
|
|
const handlePlayerStartKeyboardBindingChange = (
|
|
action: PlayerStartInputAction,
|
|
nextCode: PlayerStartKeyboardBindingCode
|
|
) => {
|
|
const nextBindings = createPlayerStartInputBindings({
|
|
keyboard: {
|
|
...playerStartInputBindingsDraft.keyboard,
|
|
[action]: nextCode
|
|
} as PlayerStartInputBindings["keyboard"],
|
|
gamepad: playerStartInputBindingsDraft.gamepad
|
|
});
|
|
|
|
setPlayerStartInputBindingsDraft(nextBindings);
|
|
scheduleDraftCommit(() =>
|
|
applyPlayerStartChange({
|
|
inputBindings: nextBindings
|
|
})
|
|
);
|
|
};
|
|
|
|
const handlePlayerStartMovementGamepadBindingChange = (
|
|
action: PlayerStartMovementAction,
|
|
nextBinding: PlayerStartGamepadBinding
|
|
) => {
|
|
const nextBindings = createPlayerStartInputBindings({
|
|
keyboard: playerStartInputBindingsDraft.keyboard,
|
|
gamepad: {
|
|
...playerStartInputBindingsDraft.gamepad,
|
|
[action]: nextBinding
|
|
} as PlayerStartInputBindings["gamepad"]
|
|
});
|
|
|
|
setPlayerStartInputBindingsDraft(nextBindings);
|
|
scheduleDraftCommit(() =>
|
|
applyPlayerStartChange({
|
|
inputBindings: nextBindings
|
|
})
|
|
);
|
|
};
|
|
|
|
const handlePlayerStartGamepadActionBindingChange = (
|
|
action: PlayerStartLocomotionAction | PlayerStartSystemAction,
|
|
nextBinding: PlayerStartGamepadActionBinding
|
|
) => {
|
|
const nextBindings = createPlayerStartInputBindings({
|
|
keyboard: playerStartInputBindingsDraft.keyboard,
|
|
gamepad: {
|
|
...playerStartInputBindingsDraft.gamepad,
|
|
[action]: nextBinding
|
|
} as PlayerStartInputBindings["gamepad"]
|
|
});
|
|
|
|
setPlayerStartInputBindingsDraft(nextBindings);
|
|
scheduleDraftCommit(() =>
|
|
applyPlayerStartChange({
|
|
inputBindings: nextBindings
|
|
})
|
|
);
|
|
};
|
|
|
|
const handlePlayerStartGamepadCameraLookBindingChange = (
|
|
nextBinding: PlayerStartGamepadCameraLookBinding
|
|
) => {
|
|
const nextBindings = createPlayerStartInputBindings({
|
|
keyboard: playerStartInputBindingsDraft.keyboard,
|
|
gamepad: {
|
|
...playerStartInputBindingsDraft.gamepad,
|
|
cameraLook: nextBinding
|
|
}
|
|
});
|
|
|
|
setPlayerStartInputBindingsDraft(nextBindings);
|
|
scheduleDraftCommit(() =>
|
|
applyPlayerStartChange({
|
|
inputBindings: nextBindings
|
|
})
|
|
);
|
|
};
|
|
|
|
const applySceneEntryChange = () => {
|
|
if (selectedSceneEntry === null) {
|
|
setStatusMessage("Select a Scene Entry before editing it.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const nextEntity = createSceneEntryEntity({
|
|
id: selectedSceneEntry.id,
|
|
name: selectedSceneEntry.name,
|
|
position: snapVec3ToGrid(
|
|
readVec3Draft(entityPositionDraft, "Scene Entry position"),
|
|
DEFAULT_GRID_SIZE
|
|
),
|
|
yawDegrees: readYawDegreesDraft(sceneEntryYawDraft)
|
|
});
|
|
|
|
commitEntityChange(
|
|
selectedSceneEntry,
|
|
nextEntity,
|
|
"Updated Scene Entry."
|
|
);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const applyNpcChange = (
|
|
overrides: {
|
|
modelAssetId?: string | null;
|
|
colliderMode?: PlayerStartColliderMode;
|
|
} = {}
|
|
) => {
|
|
if (selectedNpc === null) {
|
|
setStatusMessage("Select an NPC before editing it.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const trimmedModelAssetId = npcModelAssetIdDraft.trim();
|
|
const colliderMode = overrides.colliderMode ?? npcColliderModeDraft;
|
|
|
|
const nextEntity = createNpcEntity({
|
|
id: selectedNpc.id,
|
|
name: selectedNpc.name,
|
|
actorId: npcActorIdDraft,
|
|
presence: createNpcAlwaysPresence(),
|
|
position: snapVec3ToGrid(
|
|
readVec3Draft(entityPositionDraft, "NPC position"),
|
|
DEFAULT_GRID_SIZE
|
|
),
|
|
yawDegrees: readYawDegreesDraft(npcYawDraft),
|
|
modelAssetId:
|
|
overrides.modelAssetId !== undefined
|
|
? overrides.modelAssetId
|
|
: trimmedModelAssetId.length === 0
|
|
? null
|
|
: trimmedModelAssetId,
|
|
dialogues: selectedNpc.dialogues,
|
|
defaultDialogueId: selectedNpc.defaultDialogueId,
|
|
collider: {
|
|
mode: colliderMode,
|
|
eyeHeight: readPositiveNumberDraft(
|
|
npcEyeHeightDraft,
|
|
"NPC eye height"
|
|
),
|
|
capsuleRadius: readPositiveNumberDraft(
|
|
npcCapsuleRadiusDraft,
|
|
"NPC capsule radius"
|
|
),
|
|
capsuleHeight: readPositiveNumberDraft(
|
|
npcCapsuleHeightDraft,
|
|
"NPC capsule height"
|
|
),
|
|
boxSize: readPositiveVec3Draft(npcBoxSizeDraft, "NPC box size")
|
|
}
|
|
});
|
|
|
|
commitEntityChange(selectedNpc, nextEntity, "Updated NPC.");
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const updateSelectedNpcDialogues = (
|
|
_label: string,
|
|
successMessage: string,
|
|
mutate: (
|
|
dialogues: ProjectDialogue[],
|
|
defaultDialogueId: string | null
|
|
) => {
|
|
dialogues: ProjectDialogue[];
|
|
defaultDialogueId: string | null;
|
|
}
|
|
) => {
|
|
if (selectedNpc === null) {
|
|
setStatusMessage("Select an NPC before editing its dialogues.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const nextState = mutate(
|
|
selectedNpc.dialogues.map((dialogue) => ({
|
|
...dialogue,
|
|
lines: dialogue.lines.map((line) => ({ ...line }))
|
|
})),
|
|
selectedNpc.defaultDialogueId
|
|
);
|
|
const nextEntity = createNpcEntity({
|
|
...selectedNpc,
|
|
dialogues: nextState.dialogues,
|
|
defaultDialogueId: nextState.defaultDialogueId
|
|
});
|
|
commitEntityChange(selectedNpc, nextEntity, successMessage);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const applyPointLightChange = (overrides: { colorHex?: string } = {}) => {
|
|
if (selectedPointLight === null) {
|
|
setStatusMessage("Select a Point Light before editing it.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const nextEntity = createPointLightEntity({
|
|
id: selectedPointLight.id,
|
|
name: selectedPointLight.name,
|
|
position: snapVec3ToGrid(
|
|
readVec3Draft(entityPositionDraft, "Point Light position"),
|
|
DEFAULT_GRID_SIZE
|
|
),
|
|
colorHex: overrides.colorHex ?? pointLightColorDraft,
|
|
intensity: readNonNegativeNumberDraft(
|
|
pointLightIntensityDraft,
|
|
"Point Light intensity"
|
|
),
|
|
distance: readPositiveNumberDraft(
|
|
pointLightDistanceDraft,
|
|
"Point Light distance"
|
|
)
|
|
});
|
|
|
|
commitEntityChange(
|
|
selectedPointLight,
|
|
nextEntity,
|
|
"Updated Point Light."
|
|
);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const applySpotLightChange = (overrides: { colorHex?: string } = {}) => {
|
|
if (selectedSpotLight === null) {
|
|
setStatusMessage("Select a Spot Light before editing it.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const nextEntity = createSpotLightEntity({
|
|
id: selectedSpotLight.id,
|
|
name: selectedSpotLight.name,
|
|
position: snapVec3ToGrid(
|
|
readVec3Draft(entityPositionDraft, "Spot Light position"),
|
|
DEFAULT_GRID_SIZE
|
|
),
|
|
direction: readVec3Draft(
|
|
spotLightDirectionDraft,
|
|
"Spot Light direction"
|
|
),
|
|
colorHex: overrides.colorHex ?? spotLightColorDraft,
|
|
intensity: readNonNegativeNumberDraft(
|
|
spotLightIntensityDraft,
|
|
"Spot Light intensity"
|
|
),
|
|
distance: readPositiveNumberDraft(
|
|
spotLightDistanceDraft,
|
|
"Spot Light distance"
|
|
),
|
|
angleDegrees: readPositiveNumberDraft(
|
|
spotLightAngleDraft,
|
|
"Spot Light angle"
|
|
)
|
|
});
|
|
|
|
commitEntityChange(selectedSpotLight, nextEntity, "Updated Spot Light.");
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const applySelectedEntityDraftChange = () => {
|
|
if (selectedEntity === null) {
|
|
return;
|
|
}
|
|
|
|
switch (selectedEntity.kind) {
|
|
case "pointLight":
|
|
applyPointLightChange();
|
|
break;
|
|
case "spotLight":
|
|
applySpotLightChange();
|
|
break;
|
|
case "cameraRig":
|
|
applyCameraRigChange();
|
|
break;
|
|
case "playerStart":
|
|
applyPlayerStartChange();
|
|
break;
|
|
case "sceneEntry":
|
|
applySceneEntryChange();
|
|
break;
|
|
case "npc":
|
|
applyNpcChange();
|
|
break;
|
|
case "soundEmitter":
|
|
applySoundEmitterChange();
|
|
break;
|
|
case "triggerVolume":
|
|
applyTriggerVolumeChange();
|
|
break;
|
|
case "teleportTarget":
|
|
applyTeleportTargetChange();
|
|
break;
|
|
case "interactable":
|
|
applyInteractableChange();
|
|
break;
|
|
}
|
|
};
|
|
|
|
const applySoundEmitterChange = (
|
|
overrides: {
|
|
audioAssetId?: string | null;
|
|
autoplay?: boolean;
|
|
loop?: boolean;
|
|
} = {}
|
|
) => {
|
|
if (selectedSoundEmitter === null) {
|
|
setStatusMessage("Select a Sound Emitter before editing it.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const trimmedAudioAssetId = soundEmitterAudioAssetIdDraft.trim();
|
|
const nextAudioAssetId =
|
|
overrides.audioAssetId !== undefined
|
|
? overrides.audioAssetId
|
|
: trimmedAudioAssetId.length === 0
|
|
? null
|
|
: trimmedAudioAssetId;
|
|
const nextEntity = createSoundEmitterEntity({
|
|
id: selectedSoundEmitter.id,
|
|
name: selectedSoundEmitter.name,
|
|
position: snapVec3ToGrid(
|
|
readVec3Draft(entityPositionDraft, "Sound Emitter position"),
|
|
DEFAULT_GRID_SIZE
|
|
),
|
|
audioAssetId: nextAudioAssetId,
|
|
volume: readNonNegativeNumberDraft(
|
|
soundEmitterVolumeDraft,
|
|
"Sound Emitter volume"
|
|
),
|
|
refDistance: readPositiveNumberDraft(
|
|
soundEmitterRefDistanceDraft,
|
|
"Sound Emitter ref distance"
|
|
),
|
|
maxDistance: readPositiveNumberDraft(
|
|
soundEmitterMaxDistanceDraft,
|
|
"Sound Emitter max distance"
|
|
),
|
|
autoplay: overrides.autoplay ?? soundEmitterAutoplayDraft,
|
|
loop: overrides.loop ?? soundEmitterLoopDraft
|
|
});
|
|
|
|
commitEntityChange(
|
|
selectedSoundEmitter,
|
|
nextEntity,
|
|
"Updated Sound Emitter."
|
|
);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const applyTriggerVolumeChange = () => {
|
|
if (selectedTriggerVolume === null) {
|
|
setStatusMessage("Select a Trigger Volume before editing it.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Derive triggerOnEnter/triggerOnExit from the actual links so the flags
|
|
// stay in sync automatically — no manual checkbox needed.
|
|
const links = getInteractionLinksForSource(
|
|
editorState.document.interactionLinks,
|
|
selectedTriggerVolume.id
|
|
);
|
|
const triggerOnEnter = links.some((l) => l.trigger === "enter");
|
|
const triggerOnExit = links.some((l) => l.trigger === "exit");
|
|
|
|
const nextEntity = createTriggerVolumeEntity({
|
|
id: selectedTriggerVolume.id,
|
|
name: selectedTriggerVolume.name,
|
|
position: snapVec3ToGrid(
|
|
readVec3Draft(entityPositionDraft, "Trigger Volume position"),
|
|
DEFAULT_GRID_SIZE
|
|
),
|
|
size: snapPositiveSizeToGrid(
|
|
readVec3Draft(triggerVolumeSizeDraft, "Trigger Volume size"),
|
|
DEFAULT_GRID_SIZE
|
|
),
|
|
triggerOnEnter,
|
|
triggerOnExit
|
|
});
|
|
|
|
commitEntityChange(
|
|
selectedTriggerVolume,
|
|
nextEntity,
|
|
"Updated Trigger Volume."
|
|
);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const applyTeleportTargetChange = () => {
|
|
if (selectedTeleportTarget === null) {
|
|
setStatusMessage("Select a Teleport Target before editing it.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const nextEntity = createTeleportTargetEntity({
|
|
id: selectedTeleportTarget.id,
|
|
name: selectedTeleportTarget.name,
|
|
position: snapVec3ToGrid(
|
|
readVec3Draft(entityPositionDraft, "Teleport Target position"),
|
|
DEFAULT_GRID_SIZE
|
|
),
|
|
yawDegrees: readYawDegreesDraft(teleportTargetYawDraft)
|
|
});
|
|
|
|
commitEntityChange(
|
|
selectedTeleportTarget,
|
|
nextEntity,
|
|
"Updated Teleport Target."
|
|
);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const applyInteractableChange = (
|
|
overrides: { interactionEnabled?: boolean } = {}
|
|
) => {
|
|
if (selectedInteractable === null) {
|
|
setStatusMessage("Select an Interactable before editing it.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const nextEntity = createInteractableEntity({
|
|
id: selectedInteractable.id,
|
|
name: selectedInteractable.name,
|
|
position: snapVec3ToGrid(
|
|
readVec3Draft(entityPositionDraft, "Interactable position"),
|
|
DEFAULT_GRID_SIZE
|
|
),
|
|
radius: readPositiveNumberDraft(
|
|
interactableRadiusDraft,
|
|
"Interactable radius"
|
|
),
|
|
prompt: readInteractablePromptDraft(interactablePromptDraft),
|
|
interactionEnabled:
|
|
overrides.interactionEnabled ?? interactableEnabledDraft
|
|
});
|
|
|
|
commitEntityChange(
|
|
selectedInteractable,
|
|
nextEntity,
|
|
"Updated Interactable."
|
|
);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const commitInteractionLinkChange = (
|
|
currentLink: InteractionLink,
|
|
nextLink: InteractionLink,
|
|
successMessage: string,
|
|
label = "Update interaction link"
|
|
) => {
|
|
if (areInteractionLinksEqual(currentLink, nextLink)) {
|
|
return;
|
|
}
|
|
|
|
store.executeCommand(
|
|
createUpsertInteractionLinkCommand({
|
|
link: nextLink,
|
|
label
|
|
})
|
|
);
|
|
setStatusMessage(successMessage);
|
|
};
|
|
|
|
const getInteractionSourceEntityForLink = (
|
|
link: InteractionLink
|
|
): InteractionSourceEntity | null => {
|
|
const sourceEntity = editorState.document.entities[link.sourceEntityId];
|
|
return sourceEntity?.kind === "triggerVolume" ||
|
|
sourceEntity?.kind === "interactable" ||
|
|
sourceEntity?.kind === "npc"
|
|
? sourceEntity
|
|
: null;
|
|
};
|
|
|
|
const resolveInteractionControlTargetOption = (targetKey: string) =>
|
|
getProjectScheduleTargetOptionByKey(
|
|
projectScheduleTargetOptions,
|
|
targetKey
|
|
);
|
|
|
|
const resolveDefaultInteractionControlTargetOption = () =>
|
|
interactionControlTargetOptions.find(
|
|
(targetOption) =>
|
|
targetOption.target.kind === "entity" &&
|
|
targetOption.target.entityKind === "cameraRig"
|
|
) ??
|
|
interactionControlTargetOptions[0] ??
|
|
null;
|
|
|
|
const createInteractionControlLinkFromTargetOption = (options: {
|
|
id?: string;
|
|
sourceEntity: InteractionSourceEntity;
|
|
trigger: InteractionTriggerKind;
|
|
targetOption: ProjectScheduleTargetOption;
|
|
effectOptionId?: ProjectScheduleEffectOptionId;
|
|
previousEffect?: ControlEffect | null;
|
|
}): InteractionLink => {
|
|
const effectOptions = listProjectInteractionControlEffectOptions(
|
|
options.targetOption
|
|
);
|
|
const effectOptionId =
|
|
options.effectOptionId ??
|
|
effectOptions.find((effectOption) => {
|
|
if (
|
|
options.previousEffect === undefined ||
|
|
options.previousEffect === null
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
return (
|
|
effectOption.id ===
|
|
getProjectScheduleEffectOptionId(options.previousEffect)
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
})?.id ??
|
|
effectOptions[0]?.id;
|
|
|
|
if (effectOptionId === undefined) {
|
|
throw new Error(
|
|
"The selected control target does not expose any interaction control effects."
|
|
);
|
|
}
|
|
|
|
return createControlInteractionLink({
|
|
id: options.id,
|
|
sourceEntityId: options.sourceEntity.id,
|
|
trigger: getCanonicalInteractionLinkTrigger(
|
|
options.sourceEntity,
|
|
options.trigger
|
|
),
|
|
effect: createProjectScheduleEffectFromOption({
|
|
targetOption: options.targetOption,
|
|
effectOptionId,
|
|
previousEffect: options.previousEffect ?? null
|
|
})
|
|
});
|
|
};
|
|
|
|
const updateControlInteractionLinkEffect = (
|
|
link: InteractionLink,
|
|
successMessage: string,
|
|
mutate: (effect: ControlEffect) => void,
|
|
label = "Update interaction control link"
|
|
) => {
|
|
if (link.action.type !== "control") {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const nextEffect = cloneControlEffect(link.action.effect);
|
|
mutate(nextEffect);
|
|
commitInteractionLinkChange(
|
|
link,
|
|
createControlInteractionLink({
|
|
id: link.id,
|
|
sourceEntityId: link.sourceEntityId,
|
|
trigger: link.trigger,
|
|
effect: nextEffect
|
|
}),
|
|
successMessage,
|
|
label
|
|
);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const handleAddSequenceInteractionLink = () => {
|
|
if (selectedInteractionSource === null) {
|
|
setStatusMessage(
|
|
"Select a Trigger Volume, Interactable, or NPC before adding links."
|
|
);
|
|
return;
|
|
}
|
|
|
|
const defaultSequence = projectImpulseSequenceList[0] ?? null;
|
|
|
|
if (defaultSequence === null) {
|
|
openSequencerSequenceEditor();
|
|
setStatusMessage(
|
|
"Open the Sequencer sequence editor and author a sequence with at least one start effect before adding a sequence link."
|
|
);
|
|
return;
|
|
}
|
|
|
|
store.executeCommand(
|
|
createUpsertInteractionLinkCommand({
|
|
link: createRunSequenceInteractionLink({
|
|
sourceEntityId: selectedInteractionSource.id,
|
|
trigger: getDefaultInteractionLinkTrigger(selectedInteractionSource),
|
|
sequenceId: defaultSequence.id
|
|
}),
|
|
label: "Add run sequence interaction link"
|
|
})
|
|
);
|
|
setStatusMessage(
|
|
`Added a sequence link to the selected ${getInteractionSourceEntityLabel(selectedInteractionSource)}.`
|
|
);
|
|
};
|
|
|
|
const handleAddControlInteractionLink = () => {
|
|
if (selectedInteractionSource === null) {
|
|
setStatusMessage(
|
|
"Select a Trigger Volume, Interactable, or NPC before adding links."
|
|
);
|
|
return;
|
|
}
|
|
|
|
const targetOption = resolveDefaultInteractionControlTargetOption();
|
|
|
|
if (targetOption === null) {
|
|
setStatusMessage(
|
|
"Author a control-addressable target before adding a control link."
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
store.executeCommand(
|
|
createUpsertInteractionLinkCommand({
|
|
link: createInteractionControlLinkFromTargetOption({
|
|
sourceEntity: selectedInteractionSource,
|
|
trigger: getDefaultInteractionLinkTrigger(
|
|
selectedInteractionSource
|
|
),
|
|
targetOption
|
|
}),
|
|
label: "Add control interaction link"
|
|
})
|
|
);
|
|
setStatusMessage(
|
|
`Added a control link to the selected ${getInteractionSourceEntityLabel(selectedInteractionSource)}.`
|
|
);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const handleDeleteInteractionLink = (linkId: string) => {
|
|
try {
|
|
store.executeCommand(createDeleteInteractionLinkCommand(linkId));
|
|
setStatusMessage("Deleted interaction link.");
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const confirmDeleteSceneItem = (label: string) =>
|
|
globalThis.window.confirm(
|
|
`Delete ${label}?\n\nThis can be undone with Undo.`
|
|
);
|
|
|
|
const confirmDeleteProjectAsset = (label: string) =>
|
|
globalThis.window.confirm(
|
|
`Delete ${label} and remove its project-wide references?\n\nThis also deletes the stored binary asset data, and it can be undone with Undo.`
|
|
);
|
|
|
|
const handleSetPathVisible = (path: ScenePath, visible: boolean) => {
|
|
try {
|
|
store.executeCommand(
|
|
createSetPathAuthoredStateCommand({
|
|
pathId: path.id,
|
|
visible
|
|
})
|
|
);
|
|
setStatusMessage(
|
|
visible
|
|
? `Shown ${getPathLabelById(path.id, pathList)} in the editor.`
|
|
: `Hidden ${getPathLabelById(path.id, pathList)} in the editor.`
|
|
);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const handleSetPathEnabled = (path: ScenePath, enabled: boolean) => {
|
|
try {
|
|
store.executeCommand(
|
|
createSetPathAuthoredStateCommand({
|
|
pathId: path.id,
|
|
enabled
|
|
})
|
|
);
|
|
setStatusMessage(
|
|
enabled
|
|
? `Enabled ${getPathLabelById(path.id, pathList)}.`
|
|
: `Disabled ${getPathLabelById(path.id, pathList)}.`
|
|
);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const handleSetBrushVisible = (brush: Brush, visible: boolean) => {
|
|
try {
|
|
store.executeCommand(
|
|
createSetBoxBrushAuthoredStateCommand({
|
|
brushId: brush.id,
|
|
visible
|
|
})
|
|
);
|
|
setStatusMessage(
|
|
visible
|
|
? `Shown ${getBrushLabelById(brush.id, brushList)} in the editor and runner.`
|
|
: `Hidden ${getBrushLabelById(brush.id, brushList)} in the editor and runner.`
|
|
);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const handleSetBrushEnabled = (brush: Brush, enabled: boolean) => {
|
|
try {
|
|
store.executeCommand(
|
|
createSetBoxBrushAuthoredStateCommand({
|
|
brushId: brush.id,
|
|
enabled
|
|
})
|
|
);
|
|
setStatusMessage(
|
|
enabled
|
|
? `Enabled ${getBrushLabelById(brush.id, brushList)}.`
|
|
: `Disabled ${getBrushLabelById(brush.id, brushList)}.`
|
|
);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const handleSetModelInstanceVisible = (
|
|
modelInstance: ModelInstance,
|
|
visible: boolean
|
|
) => {
|
|
try {
|
|
store.executeCommand(
|
|
createSetModelInstanceAuthoredStateCommand({
|
|
modelInstanceId: modelInstance.id,
|
|
visible
|
|
})
|
|
);
|
|
setStatusMessage(
|
|
visible
|
|
? `Shown ${getModelInstanceDisplayLabelById(modelInstance.id, editorState.document.modelInstances, editorState.document.assets)} in the editor and runner.`
|
|
: `Hidden ${getModelInstanceDisplayLabelById(modelInstance.id, editorState.document.modelInstances, editorState.document.assets)} in the editor and runner.`
|
|
);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const handleSetModelInstanceEnabled = (
|
|
modelInstance: ModelInstance,
|
|
enabled: boolean
|
|
) => {
|
|
try {
|
|
store.executeCommand(
|
|
createSetModelInstanceAuthoredStateCommand({
|
|
modelInstanceId: modelInstance.id,
|
|
enabled
|
|
})
|
|
);
|
|
setStatusMessage(
|
|
enabled
|
|
? `Enabled ${getModelInstanceDisplayLabelById(modelInstance.id, editorState.document.modelInstances, editorState.document.assets)}.`
|
|
: `Disabled ${getModelInstanceDisplayLabelById(modelInstance.id, editorState.document.modelInstances, editorState.document.assets)}.`
|
|
);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const handleSetEntityVisible = (entity: EntityInstance, visible: boolean) => {
|
|
try {
|
|
store.executeCommand(
|
|
createSetEntityAuthoredStateCommand({
|
|
entityId: entity.id,
|
|
visible
|
|
})
|
|
);
|
|
setStatusMessage(
|
|
visible
|
|
? `Shown ${getEntityDisplayLabelById(entity.id, editorState.document.entities, editorState.document.assets)} in the editor and runner.`
|
|
: `Hidden ${getEntityDisplayLabelById(entity.id, editorState.document.entities, editorState.document.assets)} in the editor and runner.`
|
|
);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const handleSetEntityEnabled = (entity: EntityInstance, enabled: boolean) => {
|
|
try {
|
|
store.executeCommand(
|
|
createSetEntityAuthoredStateCommand({
|
|
entityId: entity.id,
|
|
enabled
|
|
})
|
|
);
|
|
setStatusMessage(
|
|
enabled
|
|
? `Enabled ${getEntityDisplayLabelById(entity.id, editorState.document.entities, editorState.document.assets)}.`
|
|
: `Disabled ${getEntityDisplayLabelById(entity.id, editorState.document.entities, editorState.document.assets)}.`
|
|
);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const handleDeleteBrush = (brushId: string) => {
|
|
const label = getBrushLabelById(brushId, brushList);
|
|
|
|
if (!confirmDeleteSceneItem(label)) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
store.executeCommand(createDeleteBoxBrushCommand(brushId));
|
|
setStatusMessage(`Deleted ${label}.`);
|
|
return true;
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const handleDeletePath = (pathId: string) => {
|
|
const label = getPathLabelById(pathId, pathList);
|
|
|
|
if (!confirmDeleteSceneItem(label)) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
store.executeCommand(createDeletePathCommand(pathId));
|
|
setStatusMessage(`Deleted ${label}.`);
|
|
return true;
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const handleDeleteTerrain = (terrainId: string) => {
|
|
const label = getTerrainLabelById(terrainId, terrainList);
|
|
|
|
if (!confirmDeleteSceneItem(label)) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
store.executeCommand(createDeleteTerrainCommand(terrainId));
|
|
setStatusMessage(`Deleted ${label}.`);
|
|
return true;
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const handleDeleteEntity = (entityId: string) => {
|
|
const label = getEntityDisplayLabelById(
|
|
entityId,
|
|
editorState.document.entities,
|
|
editorState.document.assets
|
|
);
|
|
|
|
if (!confirmDeleteSceneItem(label)) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
store.executeCommand(createDeleteEntityCommand(entityId));
|
|
setStatusMessage(`Deleted ${label}.`);
|
|
return true;
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const handleDeleteModelInstance = (modelInstanceId: string) => {
|
|
const label = getModelInstanceDisplayLabelById(
|
|
modelInstanceId,
|
|
editorState.document.modelInstances,
|
|
editorState.document.assets
|
|
);
|
|
|
|
if (!confirmDeleteSceneItem(label)) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
store.executeCommand(createDeleteModelInstanceCommand(modelInstanceId));
|
|
setStatusMessage(`Deleted ${label}.`);
|
|
return true;
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const handleDeleteProjectAsset = (asset: ProjectAssetRecord) => {
|
|
if (!confirmDeleteProjectAsset(asset.sourceName)) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
store.executeCommand(createDeleteProjectAssetCommand(asset.id));
|
|
setStatusMessage(
|
|
`Deleted ${asset.sourceName} and cleaned up project references.`
|
|
);
|
|
return true;
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const handleDeleteSelectedSceneItem = () => {
|
|
const selectedPathPoint = getSingleSelectedPathPoint(editorState.selection);
|
|
|
|
if (selectedPathPoint !== null) {
|
|
handleDeletePathPoint(selectedPathPoint.pointId);
|
|
return true;
|
|
}
|
|
|
|
if (editorState.selection.kind === "brushes") {
|
|
try {
|
|
store.executeCommand(
|
|
createDeleteSelectionCommand(
|
|
editorState.selection,
|
|
editorState.selection.ids.length === 1
|
|
? "Delete whitebox solid"
|
|
: "Delete whitebox solids"
|
|
)
|
|
);
|
|
setStatusMessage(
|
|
editorState.selection.ids.length === 1
|
|
? `Deleted ${getBrushLabelById(
|
|
editorState.selection.ids[0],
|
|
brushList
|
|
)}.`
|
|
: `Deleted ${editorState.selection.ids.length} whitebox solids.`
|
|
);
|
|
return true;
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const selectedBrushId = getSingleSelectedBrushId(editorState.selection);
|
|
|
|
if (selectedBrushId !== null) {
|
|
return handleDeleteBrush(selectedBrushId);
|
|
}
|
|
|
|
const selectedPathId = getSingleSelectedPathOwnerId(editorState.selection);
|
|
|
|
if (selectedPathId !== null) {
|
|
return handleDeletePath(selectedPathId);
|
|
}
|
|
|
|
const selectedTerrainId = getSingleSelectedTerrainId(editorState.selection);
|
|
|
|
if (selectedTerrainId !== null) {
|
|
return handleDeleteTerrain(selectedTerrainId);
|
|
}
|
|
|
|
if (editorState.selection.kind === "entities") {
|
|
try {
|
|
store.executeCommand(
|
|
createDeleteSelectionCommand(
|
|
editorState.selection,
|
|
editorState.selection.ids.length === 1
|
|
? "Delete entity"
|
|
: "Delete entities"
|
|
)
|
|
);
|
|
setStatusMessage(
|
|
editorState.selection.ids.length === 1
|
|
? `Deleted ${getEntityDisplayLabelById(
|
|
editorState.selection.ids[0],
|
|
editorState.document.entities,
|
|
editorState.document.assets
|
|
)}.`
|
|
: `Deleted ${editorState.selection.ids.length} entities.`
|
|
);
|
|
return true;
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const selectedEntityId = getSingleSelectedEntityId(editorState.selection);
|
|
|
|
if (selectedEntityId !== null) {
|
|
return handleDeleteEntity(selectedEntityId);
|
|
}
|
|
|
|
const selectedModelInstanceId = getSingleSelectedModelInstanceId(
|
|
editorState.selection
|
|
);
|
|
|
|
if (editorState.selection.kind === "modelInstances") {
|
|
try {
|
|
store.executeCommand(
|
|
createDeleteSelectionCommand(
|
|
editorState.selection,
|
|
editorState.selection.ids.length === 1
|
|
? "Delete model instance"
|
|
: "Delete model instances"
|
|
)
|
|
);
|
|
setStatusMessage(
|
|
editorState.selection.ids.length === 1
|
|
? `Deleted ${getModelInstanceDisplayLabelById(
|
|
editorState.selection.ids[0],
|
|
editorState.document.modelInstances,
|
|
editorState.document.assets
|
|
)}.`
|
|
: `Deleted ${editorState.selection.ids.length} model instances.`
|
|
);
|
|
return true;
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (selectedModelInstanceId !== null) {
|
|
return handleDeleteModelInstance(selectedModelInstanceId);
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
const handleDuplicateSelection = () => {
|
|
if (!selectionCanBeDuplicated(editorState.selection)) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
store.executeCommand(createDuplicateSelectionCommand());
|
|
|
|
const duplicatedState = store.getState();
|
|
const duplicatedSelection = duplicatedState.selection;
|
|
const canGrabDuplicatedSelection =
|
|
(duplicatedSelection.kind === "brushes" ||
|
|
duplicatedSelection.kind === "entities" ||
|
|
duplicatedSelection.kind === "modelInstances") &&
|
|
duplicatedSelection.ids.length === 1;
|
|
|
|
if (canGrabDuplicatedSelection) {
|
|
const transformSourcePanelId =
|
|
layoutMode === "quad"
|
|
? (hoveredViewportPanelId ?? activePanelId)
|
|
: activePanelId;
|
|
const transformTargetResult = resolveTransformTarget(
|
|
duplicatedState.document,
|
|
duplicatedSelection,
|
|
whiteboxSelectionMode,
|
|
duplicatedState.activeSelectionId
|
|
);
|
|
const transformTarget = transformTargetResult.target;
|
|
|
|
if (transformTarget === null) {
|
|
setStatusMessage(
|
|
transformTargetResult.message ??
|
|
"Duplicated selection, but could not start move transform."
|
|
);
|
|
return true;
|
|
}
|
|
|
|
if (duplicatedState.activeViewportPanelId !== transformSourcePanelId) {
|
|
store.setActiveViewportPanel(transformSourcePanelId);
|
|
}
|
|
|
|
store.setTransformSession(
|
|
createTransformSession({
|
|
source: "keyboard",
|
|
sourcePanelId: transformSourcePanelId,
|
|
operation: "translate",
|
|
target: transformTarget
|
|
})
|
|
);
|
|
|
|
setStatusMessage(
|
|
`Move ${getTransformTargetLabel(transformTarget).toLowerCase()} in ${getViewportPanelLabel(
|
|
transformSourcePanelId
|
|
)}. Move the pointer, press X/Y/Z to constrain, press the same axis again for local when supported, click or press Enter to commit, Escape cancels.`
|
|
);
|
|
} else {
|
|
setStatusMessage("Duplicated selection.");
|
|
}
|
|
|
|
return true;
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const updateInteractionLinkTrigger = (
|
|
link: InteractionLink,
|
|
trigger: InteractionTriggerKind
|
|
) => {
|
|
const sourceEntity = getInteractionSourceEntityForLink(link);
|
|
|
|
if (sourceEntity?.kind === "interactable" && trigger !== "click") {
|
|
setStatusMessage("Interactable links always use the click trigger.");
|
|
return;
|
|
}
|
|
|
|
if (sourceEntity?.kind === "triggerVolume" && trigger === "click") {
|
|
setStatusMessage(
|
|
"Trigger Volume links may only use enter or exit triggers."
|
|
);
|
|
return;
|
|
}
|
|
|
|
let nextLink: InteractionLink;
|
|
|
|
switch (link.action.type) {
|
|
case "teleportPlayer":
|
|
nextLink = createTeleportPlayerInteractionLink({
|
|
id: link.id,
|
|
sourceEntityId: link.sourceEntityId,
|
|
trigger,
|
|
targetEntityId: link.action.targetEntityId
|
|
});
|
|
break;
|
|
case "toggleVisibility":
|
|
nextLink = createToggleVisibilityInteractionLink({
|
|
id: link.id,
|
|
sourceEntityId: link.sourceEntityId,
|
|
trigger,
|
|
targetBrushId: link.action.targetBrushId,
|
|
visible: link.action.visible
|
|
});
|
|
break;
|
|
case "playAnimation":
|
|
nextLink = createPlayAnimationInteractionLink({
|
|
id: link.id,
|
|
sourceEntityId: link.sourceEntityId,
|
|
trigger,
|
|
targetModelInstanceId: link.action.targetModelInstanceId,
|
|
clipName: link.action.clipName,
|
|
loop: link.action.loop
|
|
});
|
|
break;
|
|
case "stopAnimation":
|
|
nextLink = createStopAnimationInteractionLink({
|
|
id: link.id,
|
|
sourceEntityId: link.sourceEntityId,
|
|
trigger,
|
|
targetModelInstanceId: link.action.targetModelInstanceId
|
|
});
|
|
break;
|
|
case "playSound":
|
|
nextLink = createPlaySoundInteractionLink({
|
|
id: link.id,
|
|
sourceEntityId: link.sourceEntityId,
|
|
trigger,
|
|
targetSoundEmitterId: link.action.targetSoundEmitterId
|
|
});
|
|
break;
|
|
case "stopSound":
|
|
nextLink = createStopSoundInteractionLink({
|
|
id: link.id,
|
|
sourceEntityId: link.sourceEntityId,
|
|
trigger,
|
|
targetSoundEmitterId: link.action.targetSoundEmitterId
|
|
});
|
|
break;
|
|
case "runSequence":
|
|
nextLink = createRunSequenceInteractionLink({
|
|
id: link.id,
|
|
sourceEntityId: link.sourceEntityId,
|
|
trigger,
|
|
sequenceId: link.action.sequenceId
|
|
});
|
|
break;
|
|
case "control":
|
|
nextLink = createControlInteractionLink({
|
|
id: link.id,
|
|
sourceEntityId: link.sourceEntityId,
|
|
trigger,
|
|
effect: link.action.effect
|
|
});
|
|
break;
|
|
}
|
|
|
|
commitInteractionLinkChange(
|
|
link,
|
|
nextLink,
|
|
`Updated ${getInteractionTriggerLabel(trigger).toLowerCase()} trigger link.`
|
|
);
|
|
};
|
|
|
|
const updateInteractionLinkActionType = (
|
|
link: InteractionLink,
|
|
actionType: InteractionLink["action"]["type"]
|
|
) => {
|
|
const sourceEntity = getInteractionSourceEntityForLink(link);
|
|
|
|
if (sourceEntity === null || link.action.type === actionType) {
|
|
return;
|
|
}
|
|
|
|
if (actionType === "control") {
|
|
const targetOption = resolveDefaultInteractionControlTargetOption();
|
|
|
|
if (targetOption === null) {
|
|
setStatusMessage(
|
|
"Author a control-addressable target before switching this link to a control effect."
|
|
);
|
|
return;
|
|
}
|
|
|
|
commitInteractionLinkChange(
|
|
link,
|
|
createInteractionControlLinkFromTargetOption({
|
|
id: link.id,
|
|
sourceEntity,
|
|
trigger: link.trigger,
|
|
targetOption
|
|
}),
|
|
"Switched link action to a control effect."
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (actionType === "runSequence") {
|
|
const defaultSequence = projectImpulseSequenceList[0] ?? null;
|
|
|
|
if (defaultSequence === null) {
|
|
openSequencerSequenceEditor();
|
|
setStatusMessage(
|
|
"Open the Sequencer sequence editor and author a sequence with at least one start effect before switching this link to run sequence."
|
|
);
|
|
return;
|
|
}
|
|
|
|
commitInteractionLinkChange(
|
|
link,
|
|
createRunSequenceInteractionLink({
|
|
id: link.id,
|
|
sourceEntityId: sourceEntity.id,
|
|
trigger: getCanonicalInteractionLinkTrigger(
|
|
sourceEntity,
|
|
link.trigger
|
|
),
|
|
sequenceId: defaultSequence.id
|
|
}),
|
|
"Switched link action to run sequence."
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (actionType === "teleportPlayer") {
|
|
const defaultTarget = teleportTargetOptions[0]?.entity;
|
|
|
|
if (
|
|
defaultTarget === undefined ||
|
|
defaultTarget.kind !== "teleportTarget"
|
|
) {
|
|
setStatusMessage(
|
|
"Author a Teleport Target before switching this link to teleport."
|
|
);
|
|
return;
|
|
}
|
|
|
|
commitInteractionLinkChange(
|
|
link,
|
|
createTeleportPlayerInteractionLink({
|
|
id: link.id,
|
|
sourceEntityId: sourceEntity.id,
|
|
trigger: link.trigger,
|
|
targetEntityId: defaultTarget.id
|
|
}),
|
|
"Switched link action to teleport player."
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (actionType === "playAnimation") {
|
|
const targetModelInstance =
|
|
(link.action.type === "playAnimation" ||
|
|
link.action.type === "stopAnimation"
|
|
? editorState.document.modelInstances[
|
|
link.action.targetModelInstanceId
|
|
]
|
|
: undefined) ?? modelInstanceDisplayList[0]?.modelInstance;
|
|
|
|
if (targetModelInstance === undefined) {
|
|
setStatusMessage(
|
|
"Place a model instance before switching this link to play animation."
|
|
);
|
|
return;
|
|
}
|
|
|
|
const asset = editorState.document.assets[targetModelInstance.assetId];
|
|
const firstClip =
|
|
asset?.kind === "model" ? (asset.metadata.animationNames[0] ?? "") : "";
|
|
|
|
if (firstClip === "") {
|
|
setStatusMessage("The model instance has no animation clips.");
|
|
return;
|
|
}
|
|
|
|
commitInteractionLinkChange(
|
|
link,
|
|
createPlayAnimationInteractionLink({
|
|
id: link.id,
|
|
sourceEntityId: sourceEntity.id,
|
|
trigger: link.trigger,
|
|
targetModelInstanceId: targetModelInstance.id,
|
|
clipName: firstClip
|
|
}),
|
|
"Switched link action to play animation."
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (actionType === "stopAnimation") {
|
|
const targetModelInstance =
|
|
(link.action.type === "playAnimation" ||
|
|
link.action.type === "stopAnimation"
|
|
? editorState.document.modelInstances[
|
|
link.action.targetModelInstanceId
|
|
]
|
|
: undefined) ?? modelInstanceDisplayList[0]?.modelInstance;
|
|
|
|
if (targetModelInstance === undefined) {
|
|
setStatusMessage(
|
|
"Place a model instance before switching this link to stop animation."
|
|
);
|
|
return;
|
|
}
|
|
|
|
commitInteractionLinkChange(
|
|
link,
|
|
createStopAnimationInteractionLink({
|
|
id: link.id,
|
|
sourceEntityId: sourceEntity.id,
|
|
trigger: link.trigger,
|
|
targetModelInstanceId: targetModelInstance.id
|
|
}),
|
|
"Switched link action to stop animation."
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (actionType === "playSound" || actionType === "stopSound") {
|
|
const targetSoundEmitter =
|
|
(link.action.type === "playSound" || link.action.type === "stopSound"
|
|
? editorState.document.entities[link.action.targetSoundEmitterId]
|
|
: undefined) ?? playableSoundEmitterOptions[0]?.entity;
|
|
|
|
if (
|
|
targetSoundEmitter === undefined ||
|
|
targetSoundEmitter.kind !== "soundEmitter"
|
|
) {
|
|
setStatusMessage(
|
|
"Author a Sound Emitter with an audio asset before switching this link to sound playback."
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (actionType === "playSound") {
|
|
commitInteractionLinkChange(
|
|
link,
|
|
createPlaySoundInteractionLink({
|
|
id: link.id,
|
|
sourceEntityId: sourceEntity.id,
|
|
trigger: link.trigger,
|
|
targetSoundEmitterId: targetSoundEmitter.id
|
|
}),
|
|
"Switched link action to play sound."
|
|
);
|
|
} else {
|
|
commitInteractionLinkChange(
|
|
link,
|
|
createStopSoundInteractionLink({
|
|
id: link.id,
|
|
sourceEntityId: sourceEntity.id,
|
|
trigger: link.trigger,
|
|
targetSoundEmitterId: targetSoundEmitter.id
|
|
}),
|
|
"Switched link action to stop sound."
|
|
);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
const defaultBrush = visibilityBrushOptions[0]?.brush;
|
|
|
|
if (defaultBrush === undefined) {
|
|
setStatusMessage(
|
|
"Author at least one whitebox solid before switching this link to visibility."
|
|
);
|
|
return;
|
|
}
|
|
|
|
commitInteractionLinkChange(
|
|
link,
|
|
createToggleVisibilityInteractionLink({
|
|
id: link.id,
|
|
sourceEntityId: sourceEntity.id,
|
|
trigger: link.trigger,
|
|
targetBrushId: defaultBrush.id
|
|
}),
|
|
"Switched link action to toggle visibility."
|
|
);
|
|
};
|
|
|
|
const updateControlInteractionLinkTarget = (
|
|
link: InteractionLink,
|
|
targetKey: string
|
|
) => {
|
|
if (link.action.type !== "control") {
|
|
return;
|
|
}
|
|
|
|
const sourceEntity = getInteractionSourceEntityForLink(link);
|
|
|
|
if (sourceEntity === null) {
|
|
setStatusMessage("Selected interaction source no longer exists.");
|
|
return;
|
|
}
|
|
|
|
const targetOption = resolveInteractionControlTargetOption(targetKey);
|
|
|
|
if (targetOption === null) {
|
|
setStatusMessage("Selected control target no longer exists.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
commitInteractionLinkChange(
|
|
link,
|
|
createInteractionControlLinkFromTargetOption({
|
|
id: link.id,
|
|
sourceEntity,
|
|
trigger: link.trigger,
|
|
targetOption,
|
|
previousEffect: link.action.effect
|
|
}),
|
|
"Updated control link target."
|
|
);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const updateControlInteractionLinkEffectOption = (
|
|
link: InteractionLink,
|
|
effectOptionId: ProjectScheduleEffectOptionId
|
|
) => {
|
|
if (link.action.type !== "control") {
|
|
return;
|
|
}
|
|
|
|
const targetOption = resolveInteractionControlTargetOption(
|
|
getControlTargetRefKey(link.action.effect.target)
|
|
);
|
|
|
|
if (targetOption === null) {
|
|
setStatusMessage("Selected control target no longer exists.");
|
|
return;
|
|
}
|
|
|
|
const effectOptions =
|
|
listProjectInteractionControlEffectOptions(targetOption);
|
|
|
|
if (
|
|
!effectOptions.some((effectOption) => effectOption.id === effectOptionId)
|
|
) {
|
|
setStatusMessage(
|
|
"Selected control effect is not available for the current target."
|
|
);
|
|
return;
|
|
}
|
|
|
|
commitInteractionLinkChange(
|
|
link,
|
|
createControlInteractionLink({
|
|
id: link.id,
|
|
sourceEntityId: link.sourceEntityId,
|
|
trigger: link.trigger,
|
|
effect: createProjectScheduleEffectFromOption({
|
|
targetOption,
|
|
effectOptionId,
|
|
previousEffect: link.action.effect
|
|
})
|
|
}),
|
|
"Updated control link effect."
|
|
);
|
|
};
|
|
|
|
const updateControlInteractionLinkNumericValue = (
|
|
link: InteractionLink,
|
|
value: number
|
|
) => {
|
|
if (!Number.isFinite(value) || value < 0) {
|
|
setStatusMessage(
|
|
"Control numeric values must be finite and zero or greater."
|
|
);
|
|
return;
|
|
}
|
|
|
|
updateControlInteractionLinkEffect(
|
|
link,
|
|
"Updated control link value.",
|
|
(effect) => {
|
|
switch (effect.type) {
|
|
case "setSoundVolume":
|
|
effect.volume = value;
|
|
return;
|
|
case "setLightIntensity":
|
|
case "setAmbientLightIntensity":
|
|
case "setSunLightIntensity":
|
|
effect.intensity = value;
|
|
return;
|
|
default:
|
|
throw new Error(
|
|
"The current control link effect does not expose a numeric value."
|
|
);
|
|
}
|
|
}
|
|
);
|
|
};
|
|
|
|
const updateControlInteractionLinkColorValue = (
|
|
link: InteractionLink,
|
|
colorHex: string
|
|
) => {
|
|
updateControlInteractionLinkEffect(
|
|
link,
|
|
"Updated control link color.",
|
|
(effect) => {
|
|
switch (effect.type) {
|
|
case "setLightColor":
|
|
case "setAmbientLightColor":
|
|
case "setSunLightColor":
|
|
effect.colorHex = colorHex;
|
|
return;
|
|
default:
|
|
throw new Error(
|
|
"The current control link effect does not expose a color value."
|
|
);
|
|
}
|
|
}
|
|
);
|
|
};
|
|
|
|
const updateControlInteractionLinkAnimationClip = (
|
|
link: InteractionLink,
|
|
clipName: string
|
|
) => {
|
|
updateControlInteractionLinkEffect(
|
|
link,
|
|
"Updated control link animation clip.",
|
|
(effect) => {
|
|
if (
|
|
effect.type !== "playModelAnimation" &&
|
|
effect.type !== "playActorAnimation"
|
|
) {
|
|
throw new Error(
|
|
"The current control link effect does not expose an animation clip."
|
|
);
|
|
}
|
|
|
|
effect.clipName = clipName;
|
|
}
|
|
);
|
|
};
|
|
|
|
const updateControlInteractionLinkAnimationLoop = (
|
|
link: InteractionLink,
|
|
loop: boolean
|
|
) => {
|
|
updateControlInteractionLinkEffect(
|
|
link,
|
|
loop
|
|
? "Control link animation now loops."
|
|
: "Control link animation now plays once.",
|
|
(effect) => {
|
|
if (
|
|
effect.type !== "playModelAnimation" &&
|
|
effect.type !== "playActorAnimation"
|
|
) {
|
|
throw new Error(
|
|
"The current control link effect does not expose animation looping."
|
|
);
|
|
}
|
|
|
|
effect.loop = loop;
|
|
}
|
|
);
|
|
};
|
|
|
|
const updateSequenceInteractionLinkTarget = (
|
|
link: InteractionLink,
|
|
sequenceId: string
|
|
) => {
|
|
if (link.action.type !== "runSequence") {
|
|
return;
|
|
}
|
|
|
|
commitInteractionLinkChange(
|
|
link,
|
|
createRunSequenceInteractionLink({
|
|
id: link.id,
|
|
sourceEntityId: link.sourceEntityId,
|
|
trigger: link.trigger,
|
|
sequenceId
|
|
}),
|
|
"Updated sequence link target."
|
|
);
|
|
};
|
|
|
|
const updateTeleportInteractionLinkTarget = (
|
|
link: InteractionLink,
|
|
targetEntityId: string
|
|
) => {
|
|
if (link.action.type !== "teleportPlayer") {
|
|
return;
|
|
}
|
|
|
|
commitInteractionLinkChange(
|
|
link,
|
|
createTeleportPlayerInteractionLink({
|
|
id: link.id,
|
|
sourceEntityId: link.sourceEntityId,
|
|
trigger: link.trigger,
|
|
targetEntityId
|
|
}),
|
|
"Updated teleport link target."
|
|
);
|
|
};
|
|
|
|
const updateVisibilityInteractionLinkTarget = (
|
|
link: InteractionLink,
|
|
targetBrushId: string
|
|
) => {
|
|
if (link.action.type !== "toggleVisibility") {
|
|
return;
|
|
}
|
|
|
|
commitInteractionLinkChange(
|
|
link,
|
|
createToggleVisibilityInteractionLink({
|
|
id: link.id,
|
|
sourceEntityId: link.sourceEntityId,
|
|
trigger: link.trigger,
|
|
targetBrushId,
|
|
visible: link.action.visible
|
|
}),
|
|
"Updated visibility link target."
|
|
);
|
|
};
|
|
|
|
const updateVisibilityInteractionMode = (
|
|
link: InteractionLink,
|
|
mode: "toggle" | "show" | "hide"
|
|
) => {
|
|
if (link.action.type !== "toggleVisibility") {
|
|
return;
|
|
}
|
|
|
|
commitInteractionLinkChange(
|
|
link,
|
|
createToggleVisibilityInteractionLink({
|
|
id: link.id,
|
|
sourceEntityId: link.sourceEntityId,
|
|
trigger: link.trigger,
|
|
targetBrushId: link.action.targetBrushId,
|
|
visible: readVisibilityModeSelectValue(mode)
|
|
}),
|
|
"Updated visibility link mode."
|
|
);
|
|
};
|
|
|
|
const updateSoundInteractionLinkTarget = (
|
|
link: InteractionLink,
|
|
targetSoundEmitterId: string
|
|
) => {
|
|
if (link.action.type !== "playSound" && link.action.type !== "stopSound") {
|
|
return;
|
|
}
|
|
|
|
if (link.action.type === "playSound") {
|
|
commitInteractionLinkChange(
|
|
link,
|
|
createPlaySoundInteractionLink({
|
|
id: link.id,
|
|
sourceEntityId: link.sourceEntityId,
|
|
trigger: link.trigger,
|
|
targetSoundEmitterId
|
|
}),
|
|
"Updated play sound link target."
|
|
);
|
|
} else {
|
|
commitInteractionLinkChange(
|
|
link,
|
|
createStopSoundInteractionLink({
|
|
id: link.id,
|
|
sourceEntityId: link.sourceEntityId,
|
|
trigger: link.trigger,
|
|
targetSoundEmitterId
|
|
}),
|
|
"Updated stop sound link target."
|
|
);
|
|
}
|
|
};
|
|
|
|
const updateAnimationInteractionLinkTarget = (
|
|
link: InteractionLink,
|
|
targetModelInstanceId: string
|
|
) => {
|
|
if (
|
|
link.action.type !== "playAnimation" &&
|
|
link.action.type !== "stopAnimation"
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (link.action.type === "playAnimation") {
|
|
commitInteractionLinkChange(
|
|
link,
|
|
createPlayAnimationInteractionLink({
|
|
id: link.id,
|
|
sourceEntityId: link.sourceEntityId,
|
|
trigger: link.trigger,
|
|
targetModelInstanceId,
|
|
clipName: link.action.clipName,
|
|
loop: link.action.loop
|
|
}),
|
|
"Updated play animation link target."
|
|
);
|
|
} else {
|
|
commitInteractionLinkChange(
|
|
link,
|
|
createStopAnimationInteractionLink({
|
|
id: link.id,
|
|
sourceEntityId: link.sourceEntityId,
|
|
trigger: link.trigger,
|
|
targetModelInstanceId
|
|
}),
|
|
"Updated stop animation link target."
|
|
);
|
|
}
|
|
};
|
|
|
|
const updatePlayAnimationLinkClip = (
|
|
link: InteractionLink,
|
|
clipName: string
|
|
) => {
|
|
if (link.action.type !== "playAnimation") {
|
|
return;
|
|
}
|
|
|
|
commitInteractionLinkChange(
|
|
link,
|
|
createPlayAnimationInteractionLink({
|
|
id: link.id,
|
|
sourceEntityId: link.sourceEntityId,
|
|
trigger: link.trigger,
|
|
targetModelInstanceId: link.action.targetModelInstanceId,
|
|
clipName,
|
|
loop: link.action.loop
|
|
}),
|
|
"Updated play animation clip."
|
|
);
|
|
};
|
|
|
|
const updatePlayAnimationLinkLoop = (
|
|
link: InteractionLink,
|
|
loop: boolean
|
|
) => {
|
|
if (link.action.type !== "playAnimation") {
|
|
return;
|
|
}
|
|
|
|
commitInteractionLinkChange(
|
|
link,
|
|
createPlayAnimationInteractionLink({
|
|
id: link.id,
|
|
sourceEntityId: link.sourceEntityId,
|
|
trigger: link.trigger,
|
|
targetModelInstanceId: link.action.targetModelInstanceId,
|
|
clipName: link.action.clipName,
|
|
loop
|
|
}),
|
|
"Updated play animation loop setting."
|
|
);
|
|
};
|
|
|
|
const renderInteractionLinksSection = (
|
|
sourceEntity: InteractionSourceEntity,
|
|
links: InteractionLink[]
|
|
) => (
|
|
<div className="form-section">
|
|
<div className="label">Links</div>
|
|
<div className="material-summary">
|
|
Links can either run a reusable sequence or dispatch a shared control
|
|
effect directly. Use sequences for multi-step reusable behavior. Use
|
|
control links for small direct state changes such as camera overrides.
|
|
</div>
|
|
{links.length === 0 ? (
|
|
<div className="outliner-empty">
|
|
{`No links authored for this ${getInteractionSourceEntityLabel(sourceEntity)} yet.`}
|
|
</div>
|
|
) : (
|
|
<div className="outliner-list">
|
|
{links.map((link, index) => (
|
|
<div key={link.id} className="outliner-item">
|
|
<div className="outliner-item__select">
|
|
<span className="outliner-item__title">{`Link ${index + 1}`}</span>
|
|
<span className="outliner-item__meta">
|
|
{getInteractionActionLabel(link)}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="vector-inputs vector-inputs--two">
|
|
<label className="form-field">
|
|
<span className="label">Trigger</span>
|
|
{sourceEntity.kind === "triggerVolume" ? (
|
|
<select
|
|
data-testid={`interaction-link-trigger-${link.id}`}
|
|
className="text-input"
|
|
value={getCanonicalInteractionLinkTrigger(
|
|
sourceEntity,
|
|
link.trigger
|
|
)}
|
|
onChange={(event) =>
|
|
updateInteractionLinkTrigger(
|
|
link,
|
|
event.currentTarget.value as InteractionTriggerKind
|
|
)
|
|
}
|
|
>
|
|
<option value="enter">On Enter</option>
|
|
<option value="exit">On Exit</option>
|
|
</select>
|
|
) : (
|
|
<input
|
|
data-testid={`interaction-link-trigger-${link.id}`}
|
|
className="text-input"
|
|
type="text"
|
|
value={getInteractionTriggerLabel(
|
|
getCanonicalInteractionLinkTrigger(
|
|
sourceEntity,
|
|
link.trigger
|
|
)
|
|
)}
|
|
readOnly
|
|
/>
|
|
)}
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Action</span>
|
|
<select
|
|
data-testid={`interaction-link-action-${link.id}`}
|
|
className="text-input"
|
|
value={link.action.type}
|
|
onChange={(event) =>
|
|
updateInteractionLinkActionType(
|
|
link,
|
|
event.currentTarget
|
|
.value as InteractionLink["action"]["type"]
|
|
)
|
|
}
|
|
>
|
|
<option value="runSequence">Run Sequence</option>
|
|
<option value="control">Control Effect</option>
|
|
{isLegacyInteractionActionType(link.action.type) ? (
|
|
<option value={link.action.type}>
|
|
{getInteractionActionLabel(link)} (Legacy)
|
|
</option>
|
|
) : null}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
{isLegacyInteractionActionType(link.action.type) ? (
|
|
<div className="material-summary">
|
|
This link still uses a legacy direct action. New authoring
|
|
should move this behavior into a reusable sequence effect and
|
|
switch the link to Run Sequence.
|
|
</div>
|
|
) : null}
|
|
|
|
{link.action.type === "teleportPlayer" ? (
|
|
<div className="form-section">
|
|
<label className="form-field">
|
|
<span className="label">Target</span>
|
|
<select
|
|
data-testid={`interaction-link-teleport-target-${link.id}`}
|
|
className="text-input"
|
|
value={link.action.targetEntityId}
|
|
onChange={(event) =>
|
|
updateTeleportInteractionLinkTarget(
|
|
link,
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
>
|
|
{teleportTargetOptions.map(({ entity, label }) => (
|
|
<option key={entity.id} value={entity.id}>
|
|
{label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
) : link.action.type === "toggleVisibility" ? (
|
|
<div className="form-section">
|
|
<div className="vector-inputs vector-inputs--two">
|
|
<label className="form-field">
|
|
<span className="label">Solid</span>
|
|
<select
|
|
data-testid={`interaction-link-visibility-target-${link.id}`}
|
|
className="text-input"
|
|
value={link.action.targetBrushId}
|
|
onChange={(event) =>
|
|
updateVisibilityInteractionLinkTarget(
|
|
link,
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
>
|
|
{visibilityBrushOptions.map(({ brush, label }) => (
|
|
<option key={brush.id} value={brush.id}>
|
|
{label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Mode</span>
|
|
<select
|
|
data-testid={`interaction-link-visibility-mode-${link.id}`}
|
|
className="text-input"
|
|
value={getVisibilityModeSelectValue(
|
|
link.action.visible
|
|
)}
|
|
onChange={(event) =>
|
|
updateVisibilityInteractionMode(
|
|
link,
|
|
event.currentTarget.value as ReturnType<
|
|
typeof getVisibilityModeSelectValue
|
|
>
|
|
)
|
|
}
|
|
>
|
|
<option value="toggle">Toggle</option>
|
|
<option value="show">Show</option>
|
|
<option value="hide">Hide</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
) : link.action.type === "playAnimation" ? (
|
|
<div className="form-section">
|
|
<div className="vector-inputs vector-inputs--two">
|
|
<label className="form-field">
|
|
<span className="label">Instance</span>
|
|
<select
|
|
data-testid={`interaction-link-play-anim-instance-${link.id}`}
|
|
className="text-input"
|
|
value={link.action.targetModelInstanceId}
|
|
onChange={(event) =>
|
|
updateAnimationInteractionLinkTarget(
|
|
link,
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
>
|
|
{modelInstanceDisplayList.map(
|
|
({ modelInstance, label }) => (
|
|
<option
|
|
key={modelInstance.id}
|
|
value={modelInstance.id}
|
|
>
|
|
{label}
|
|
</option>
|
|
)
|
|
)}
|
|
</select>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Clip</span>
|
|
<select
|
|
data-testid={`interaction-link-play-anim-clip-${link.id}`}
|
|
className="text-input"
|
|
value={link.action.clipName}
|
|
onChange={(event) =>
|
|
updatePlayAnimationLinkClip(
|
|
link,
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
>
|
|
{(
|
|
editorState.document.assets[
|
|
editorState.document.modelInstances[
|
|
link.action.targetModelInstanceId
|
|
]?.assetId ?? ""
|
|
] as
|
|
| {
|
|
kind: "model";
|
|
metadata: { animationNames: string[] };
|
|
}
|
|
| undefined
|
|
)?.metadata.animationNames.map((name) => (
|
|
<option key={name} value={name}>
|
|
{name}
|
|
</option>
|
|
)) ?? (
|
|
<option value={link.action.clipName}>
|
|
{link.action.clipName}
|
|
</option>
|
|
)}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<label className="form-field">
|
|
<input
|
|
type="checkbox"
|
|
data-testid={`interaction-link-play-anim-loop-${link.id}`}
|
|
checked={link.action.loop !== false}
|
|
onChange={(event) =>
|
|
updatePlayAnimationLinkLoop(
|
|
link,
|
|
event.currentTarget.checked
|
|
)
|
|
}
|
|
/>
|
|
<span className="label">Loop</span>
|
|
</label>
|
|
</div>
|
|
) : link.action.type === "playSound" ||
|
|
link.action.type === "stopSound" ? (
|
|
<div className="form-section">
|
|
<label className="form-field">
|
|
<span className="label">Emitter</span>
|
|
<select
|
|
data-testid={`interaction-link-sound-target-${link.id}`}
|
|
className="text-input"
|
|
value={link.action.targetSoundEmitterId}
|
|
onChange={(event) =>
|
|
updateSoundInteractionLinkTarget(
|
|
link,
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
>
|
|
{soundEmitterOptions.map(({ entity, label }) => (
|
|
<option key={entity.id} value={entity.id}>
|
|
{label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
) : link.action.type === "runSequence" ? (
|
|
(() => {
|
|
const sequenceId = link.action.sequenceId;
|
|
|
|
return (
|
|
<div className="form-section">
|
|
<label className="form-field">
|
|
<span className="label">Sequence</span>
|
|
<select
|
|
className="text-input"
|
|
value={sequenceId}
|
|
onChange={(event) =>
|
|
updateSequenceInteractionLinkTarget(
|
|
link,
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
>
|
|
{projectImpulseSequenceList.map((sequence) => (
|
|
<option key={sequence.id} value={sequence.id}>
|
|
{sequence.title}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<div className="inline-actions">
|
|
<button
|
|
className="toolbar__button toolbar__button--compact"
|
|
type="button"
|
|
onClick={() =>
|
|
openSequencerSequenceEditor(sequenceId)
|
|
}
|
|
>
|
|
Edit in Sequencer
|
|
</button>
|
|
</div>
|
|
<div className="material-summary">
|
|
Run Sequence links can reference any authored sequence,
|
|
and interactions fire that sequence from the start.
|
|
</div>
|
|
</div>
|
|
);
|
|
})()
|
|
) : link.action.type === "stopAnimation" ? (
|
|
<div className="form-section">
|
|
<label className="form-field">
|
|
<span className="label">Instance</span>
|
|
<select
|
|
data-testid={`interaction-link-stop-anim-instance-${link.id}`}
|
|
className="text-input"
|
|
value={link.action.targetModelInstanceId}
|
|
onChange={(event) =>
|
|
updateAnimationInteractionLinkTarget(
|
|
link,
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
>
|
|
{modelInstanceDisplayList.map(
|
|
({ modelInstance, label }) => (
|
|
<option
|
|
key={modelInstance.id}
|
|
value={modelInstance.id}
|
|
>
|
|
{label}
|
|
</option>
|
|
)
|
|
)}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
) : link.action.type === "control" ? (
|
|
(() => {
|
|
const targetOption = resolveInteractionControlTargetOption(
|
|
getControlTargetRefKey(link.action.effect.target)
|
|
);
|
|
const effectOptions =
|
|
targetOption === null
|
|
? []
|
|
: listProjectInteractionControlEffectOptions(
|
|
targetOption
|
|
);
|
|
const effectOptionId =
|
|
targetOption === null
|
|
? null
|
|
: (() => {
|
|
try {
|
|
const nextEffectOptionId =
|
|
getProjectScheduleEffectOptionId(
|
|
link.action.effect
|
|
);
|
|
|
|
return effectOptions.some(
|
|
(effectOption) =>
|
|
effectOption.id === nextEffectOptionId
|
|
)
|
|
? nextEffectOptionId
|
|
: null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
})();
|
|
const selectedEffectOption =
|
|
effectOptionId === null
|
|
? null
|
|
: (effectOptions.find(
|
|
(effectOption) => effectOption.id === effectOptionId
|
|
) ?? null);
|
|
|
|
if (targetOption === null || selectedEffectOption === null) {
|
|
return (
|
|
<div className="form-section">
|
|
<div className="material-summary">
|
|
{`${formatControlEffectValue(link.action.effect)}. This control link is preserved, but the current inspector can only edit effects exposed through the shared control catalog.`}
|
|
</div>
|
|
<div className="vector-inputs vector-inputs--two">
|
|
<label className="form-field">
|
|
<span className="label">Target</span>
|
|
<input
|
|
className="text-input"
|
|
type="text"
|
|
value={formatControlTargetRef(
|
|
link.action.effect.target
|
|
)}
|
|
readOnly
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Value</span>
|
|
<input
|
|
className="text-input"
|
|
type="text"
|
|
value={formatControlEffectValue(
|
|
link.action.effect
|
|
)}
|
|
readOnly
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="form-section">
|
|
<div className="vector-inputs vector-inputs--two">
|
|
<label className="form-field">
|
|
<span className="label">Target</span>
|
|
<select
|
|
data-testid={`interaction-link-control-target-${link.id}`}
|
|
className="select-input"
|
|
value={targetOption.key}
|
|
onChange={(event) =>
|
|
updateControlInteractionLinkTarget(
|
|
link,
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
>
|
|
{interactionControlTargetOptions.map((option) => (
|
|
<option key={option.key} value={option.key}>
|
|
{option.groupLabel} · {option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Effect</span>
|
|
<select
|
|
data-testid={`interaction-link-control-effect-${link.id}`}
|
|
className="select-input"
|
|
value={selectedEffectOption.id}
|
|
onChange={(event) =>
|
|
updateControlInteractionLinkEffectOption(
|
|
link,
|
|
event.currentTarget
|
|
.value as ProjectScheduleEffectOptionId
|
|
)
|
|
}
|
|
>
|
|
{effectOptions.map((option) => (
|
|
<option key={option.id} value={option.id}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
|
|
{selectedEffectOption.valueKind === "number" ? (
|
|
<label className="form-field">
|
|
<span className="label">
|
|
{selectedEffectOption.valueLabel ?? "Value"}
|
|
</span>
|
|
<input
|
|
key={`${link.id}-${effectOptionId}-numeric`}
|
|
data-testid={`interaction-link-control-number-${link.id}`}
|
|
className="text-input"
|
|
type="number"
|
|
min={selectedEffectOption.min ?? 0}
|
|
step={selectedEffectOption.step ?? 0.1}
|
|
defaultValue={
|
|
getControlEffectNumericValue(
|
|
link.action.effect
|
|
) ?? 0
|
|
}
|
|
onBlur={(event) =>
|
|
updateControlInteractionLinkNumericValue(
|
|
link,
|
|
Number(event.currentTarget.value)
|
|
)
|
|
}
|
|
onKeyDown={(event) =>
|
|
commitOnEnter(event, () =>
|
|
updateControlInteractionLinkNumericValue(
|
|
link,
|
|
Number(event.currentTarget.value)
|
|
)
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
) : null}
|
|
|
|
{selectedEffectOption.valueKind === "color" ? (
|
|
<label className="form-field">
|
|
<span className="label">
|
|
{selectedEffectOption.valueLabel ?? "Color"}
|
|
</span>
|
|
<input
|
|
data-testid={`interaction-link-control-color-${link.id}`}
|
|
className="color-input"
|
|
type="color"
|
|
value={
|
|
getControlEffectColorValue(link.action.effect) ??
|
|
"#ffffff"
|
|
}
|
|
onChange={(event) =>
|
|
updateControlInteractionLinkColorValue(
|
|
link,
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
) : null}
|
|
|
|
{link.action.effect.type === "playModelAnimation" ||
|
|
link.action.effect.type === "playActorAnimation" ? (
|
|
<>
|
|
<label className="form-field">
|
|
<span className="label">Clip</span>
|
|
<select
|
|
data-testid={`interaction-link-control-animation-clip-${link.id}`}
|
|
className="select-input"
|
|
value={link.action.effect.clipName}
|
|
onChange={(event) =>
|
|
updateControlInteractionLinkAnimationClip(
|
|
link,
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
>
|
|
{(link.action.effect.type === "playActorAnimation"
|
|
? targetOption.defaults.actorAnimationClipNames
|
|
: targetOption.defaults.animationClipNames
|
|
)?.map((clipName) => (
|
|
<option key={clipName} value={clipName}>
|
|
{clipName}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="form-field form-field--inline">
|
|
<input
|
|
data-testid={`interaction-link-control-animation-loop-${link.id}`}
|
|
type="checkbox"
|
|
checked={link.action.effect.loop !== false}
|
|
onChange={(event) =>
|
|
updateControlInteractionLinkAnimationLoop(
|
|
link,
|
|
event.currentTarget.checked
|
|
)
|
|
}
|
|
/>
|
|
<span className="label">Loop</span>
|
|
</label>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
);
|
|
})()
|
|
) : null}
|
|
|
|
<div className="inline-actions">
|
|
<button
|
|
className="toolbar__button"
|
|
type="button"
|
|
data-testid={`delete-interaction-link-${link.id}`}
|
|
onClick={() => handleDeleteInteractionLink(link.id)}
|
|
>
|
|
Delete Link
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div className="inline-actions">
|
|
<button
|
|
className="toolbar__button"
|
|
type="button"
|
|
onClick={handleAddSequenceInteractionLink}
|
|
>
|
|
Add Sequence Link
|
|
</button>
|
|
<button
|
|
className="toolbar__button"
|
|
type="button"
|
|
onClick={handleAddControlInteractionLink}
|
|
>
|
|
Add Control Link
|
|
</button>
|
|
<button
|
|
className="toolbar__button"
|
|
type="button"
|
|
onClick={() => openSequencerSequenceEditor()}
|
|
>
|
|
Open Sequence Editor
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
const applyWorldSettings = (
|
|
nextWorld: WorldSettings,
|
|
label: string,
|
|
successMessage: string
|
|
) => {
|
|
if (areWorldSettingsEqual(editorState.document.world, nextWorld)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
store.executeCommand(
|
|
createSetWorldSettingsCommand({
|
|
label,
|
|
world: nextWorld
|
|
})
|
|
);
|
|
setStatusMessage(successMessage);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const applySceneProjectTimeLightingEnabled = (enabled: boolean) => {
|
|
applyWorldSettings(
|
|
{
|
|
...editorState.document.world,
|
|
projectTimeLightingEnabled: enabled
|
|
},
|
|
enabled
|
|
? "Enable scene project time lighting"
|
|
: "Disable scene project time lighting",
|
|
enabled
|
|
? "This scene now follows the global project time profile."
|
|
: "This scene now keeps its authored world lighting and sky."
|
|
);
|
|
};
|
|
|
|
const applySceneShowCelestialBodies = (enabled: boolean) => {
|
|
applyWorldSettings(
|
|
{
|
|
...editorState.document.world,
|
|
showCelestialBodies: enabled
|
|
},
|
|
enabled
|
|
? "Enable celestial body visuals"
|
|
: "Disable celestial body visuals",
|
|
enabled
|
|
? "Sun and moon visuals now render in the active sky."
|
|
: "Sun and moon visuals are now hidden."
|
|
);
|
|
};
|
|
|
|
const applyShaderSkySettings = (
|
|
label: string,
|
|
successMessage: string,
|
|
mutate: (shaderSky: WorldShaderSkySettings) => void
|
|
) => {
|
|
const nextWorld = cloneWorldSettings(editorState.document.world);
|
|
mutate(nextWorld.shaderSky);
|
|
applyWorldSettings(nextWorld, label, successMessage);
|
|
};
|
|
|
|
const applyAdvancedRenderingSettings = (
|
|
label: string,
|
|
successMessage: string,
|
|
mutate: (advancedRendering: AdvancedRenderingSettings) => void
|
|
) => {
|
|
const nextWorld = cloneWorldSettings(editorState.document.world);
|
|
mutate(nextWorld.advancedRendering);
|
|
applyWorldSettings(nextWorld, label, successMessage);
|
|
};
|
|
|
|
const applyWorldBackgroundMode = (
|
|
mode: WorldBackgroundMode,
|
|
imageAssetId?: string
|
|
) => {
|
|
const nextWorld = cloneWorldSettings(editorState.document.world);
|
|
|
|
if (
|
|
mode === "shader" &&
|
|
editorState.document.world.background.mode !== "shader"
|
|
) {
|
|
nextWorld.shaderSky = syncWorldShaderSkyDayGradientToBackground(
|
|
nextWorld.shaderSky,
|
|
editorState.document.world.background
|
|
);
|
|
}
|
|
|
|
if (mode === "image") {
|
|
const currentBackgroundAssetId =
|
|
editorState.document.world.background.mode === "image"
|
|
? editorState.document.world.background.assetId
|
|
: null;
|
|
const nextImageAssetId =
|
|
imageAssetId ??
|
|
(currentBackgroundAssetId !== null &&
|
|
editorState.document.assets[currentBackgroundAssetId]?.kind === "image"
|
|
? currentBackgroundAssetId
|
|
: imageAssetList[0]?.id);
|
|
|
|
if (nextImageAssetId === undefined) {
|
|
setStatusMessage(
|
|
"Import an image asset before using an image background."
|
|
);
|
|
return;
|
|
}
|
|
|
|
applyWorldSettings(
|
|
{
|
|
...nextWorld,
|
|
background: changeWorldBackgroundMode(
|
|
editorState.document.world.background,
|
|
"image",
|
|
nextImageAssetId,
|
|
DEFAULT_TIME_PHASE_IMAGE_ENVIRONMENT_INTENSITY,
|
|
nextWorld.shaderSky
|
|
)
|
|
},
|
|
"Set world background image",
|
|
`World background set to ${editorState.document.assets[nextImageAssetId]?.sourceName ?? nextImageAssetId}.`
|
|
);
|
|
return;
|
|
}
|
|
|
|
const nextBackground = changeWorldBackgroundMode(
|
|
editorState.document.world.background,
|
|
mode,
|
|
undefined,
|
|
DEFAULT_TIME_PHASE_IMAGE_ENVIRONMENT_INTENSITY,
|
|
nextWorld.shaderSky
|
|
);
|
|
|
|
applyWorldSettings(
|
|
{
|
|
...nextWorld,
|
|
background: nextBackground
|
|
},
|
|
"Set world background mode",
|
|
mode === "solid"
|
|
? "World background set to a solid color."
|
|
: mode === "verticalGradient"
|
|
? "World background set to a vertical gradient."
|
|
: "World background set to the default shader sky."
|
|
);
|
|
};
|
|
|
|
const applyWorldBackgroundColor = (colorHex: string) => {
|
|
if (editorState.document.world.background.mode !== "solid") {
|
|
return;
|
|
}
|
|
|
|
applyWorldSettings(
|
|
{
|
|
...editorState.document.world,
|
|
background: {
|
|
mode: "solid",
|
|
colorHex
|
|
}
|
|
},
|
|
"Set world background color",
|
|
"Updated the world background color."
|
|
);
|
|
};
|
|
|
|
const applyWorldGradientColor = (
|
|
edge: "top" | "bottom",
|
|
colorHex: string
|
|
) => {
|
|
if (editorState.document.world.background.mode !== "verticalGradient") {
|
|
return;
|
|
}
|
|
|
|
applyWorldSettings(
|
|
{
|
|
...editorState.document.world,
|
|
background:
|
|
edge === "top"
|
|
? {
|
|
...editorState.document.world.background,
|
|
topColorHex: colorHex
|
|
}
|
|
: {
|
|
...editorState.document.world.background,
|
|
bottomColorHex: colorHex
|
|
}
|
|
},
|
|
edge === "top"
|
|
? "Set world gradient top color"
|
|
: "Set world gradient bottom color",
|
|
edge === "top"
|
|
? "Updated the world gradient top color."
|
|
: "Updated the world gradient bottom color."
|
|
);
|
|
};
|
|
|
|
const applyShaderSkyNumericSetting = (
|
|
label: string,
|
|
successMessage: string,
|
|
value: number,
|
|
mutate: (shaderSky: WorldShaderSkySettings, nextValue: number) => void
|
|
) => {
|
|
if (!Number.isFinite(value)) {
|
|
return;
|
|
}
|
|
|
|
applyShaderSkySettings(label, successMessage, (shaderSky) =>
|
|
mutate(shaderSky, value)
|
|
);
|
|
};
|
|
|
|
const applyShaderSkyDayColor = (edge: "top" | "bottom", colorHex: string) => {
|
|
applyShaderSkySettings(
|
|
edge === "top"
|
|
? "Set shader sky day top color"
|
|
: "Set shader sky day bottom color",
|
|
edge === "top"
|
|
? "Updated the shader sky day top color."
|
|
: "Updated the shader sky day bottom color.",
|
|
(shaderSky) => {
|
|
if (edge === "top") {
|
|
shaderSky.dayTopColorHex = colorHex;
|
|
return;
|
|
}
|
|
|
|
shaderSky.dayBottomColorHex = colorHex;
|
|
}
|
|
);
|
|
};
|
|
|
|
const applyShaderSkyAuroraEnabled = (enabled: boolean) => {
|
|
applyShaderSkySettings(
|
|
enabled ? "Enable shader sky aurora" : "Disable shader sky aurora",
|
|
enabled
|
|
? "Shader sky aurora is now active."
|
|
: "Shader sky aurora is now hidden.",
|
|
(shaderSky) => {
|
|
shaderSky.aurora.enabled = enabled;
|
|
}
|
|
);
|
|
};
|
|
|
|
const applyCelestialOrbitSettings = (
|
|
label: string,
|
|
successMessage: string,
|
|
mutate: (world: WorldSettings) => void
|
|
) => {
|
|
const nextWorld = cloneWorldSettings(editorState.document.world);
|
|
mutate(nextWorld);
|
|
nextWorld.sunLight.direction = resolveWorldCelestialOrbitPeakDirection(
|
|
nextWorld.celestialOrbits.sun
|
|
);
|
|
applyWorldSettings(nextWorld, label, successMessage);
|
|
};
|
|
|
|
const applyCelestialOrbitNumericSetting = (
|
|
label: string,
|
|
successMessage: string,
|
|
value: number,
|
|
mutate: (world: WorldSettings, nextValue: number) => void
|
|
) => {
|
|
if (!Number.isFinite(value)) {
|
|
return;
|
|
}
|
|
|
|
applyCelestialOrbitSettings(label, successMessage, (world) =>
|
|
mutate(world, value)
|
|
);
|
|
};
|
|
|
|
const applyBackgroundEnvironmentIntensity = () => {
|
|
if (editorState.document.world.background.mode !== "image") {
|
|
return;
|
|
}
|
|
|
|
const intensity = readNonNegativeNumberDraft(
|
|
backgroundEnvironmentIntensityDraft,
|
|
"Environment intensity"
|
|
);
|
|
applyWorldSettings(
|
|
{
|
|
...editorState.document.world,
|
|
background: {
|
|
...editorState.document.world.background,
|
|
environmentIntensity: intensity
|
|
}
|
|
},
|
|
"Set background environment intensity",
|
|
"Updated the background environment intensity."
|
|
);
|
|
};
|
|
|
|
const applyAmbientLightColor = (colorHex: string) => {
|
|
applyWorldSettings(
|
|
{
|
|
...editorState.document.world,
|
|
ambientLight: {
|
|
...editorState.document.world.ambientLight,
|
|
colorHex
|
|
}
|
|
},
|
|
"Set world ambient light color",
|
|
"Updated the world ambient light color."
|
|
);
|
|
};
|
|
|
|
const applyAmbientLightIntensity = () => {
|
|
try {
|
|
applyWorldSettings(
|
|
{
|
|
...editorState.document.world,
|
|
ambientLight: {
|
|
...editorState.document.world.ambientLight,
|
|
intensity: readNonNegativeNumberDraft(
|
|
ambientLightIntensityDraft,
|
|
"Ambient light intensity"
|
|
)
|
|
}
|
|
},
|
|
"Set world ambient light intensity",
|
|
"Updated the world ambient light intensity."
|
|
);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const applySunLightColor = (colorHex: string) => {
|
|
applyWorldSettings(
|
|
{
|
|
...editorState.document.world,
|
|
sunLight: {
|
|
...editorState.document.world.sunLight,
|
|
colorHex
|
|
}
|
|
},
|
|
"Set world sun color",
|
|
"Updated the world sun color."
|
|
);
|
|
};
|
|
|
|
const applySunLightIntensity = () => {
|
|
try {
|
|
applyWorldSettings(
|
|
{
|
|
...editorState.document.world,
|
|
sunLight: {
|
|
...editorState.document.world.sunLight,
|
|
intensity: readNonNegativeNumberDraft(
|
|
sunLightIntensityDraft,
|
|
"Sun intensity"
|
|
)
|
|
}
|
|
},
|
|
"Set world sun intensity",
|
|
"Updated the world sun intensity."
|
|
);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const applyAdvancedRenderingEnabled = (enabled: boolean) => {
|
|
applyAdvancedRenderingSettings(
|
|
"Set advanced rendering",
|
|
enabled ? "Advanced rendering enabled." : "Advanced rendering disabled.",
|
|
(advancedRendering) => {
|
|
advancedRendering.enabled = enabled;
|
|
}
|
|
);
|
|
};
|
|
|
|
const applyAdvancedRenderingShadowsEnabled = (enabled: boolean) => {
|
|
applyAdvancedRenderingSettings(
|
|
"Set advanced rendering shadows",
|
|
enabled
|
|
? "Advanced rendering shadows enabled."
|
|
: "Advanced rendering shadows disabled.",
|
|
(advancedRendering) => {
|
|
advancedRendering.shadows.enabled = enabled;
|
|
}
|
|
);
|
|
};
|
|
|
|
const applyAdvancedRenderingShadowMapSize = (
|
|
shadowMapSize: AdvancedRenderingShadowMapSize
|
|
) => {
|
|
applyAdvancedRenderingSettings(
|
|
"Set advanced rendering shadow map size",
|
|
"Updated the shadow map size.",
|
|
(advancedRendering) => {
|
|
advancedRendering.shadows.mapSize = shadowMapSize;
|
|
}
|
|
);
|
|
};
|
|
|
|
const applyAdvancedRenderingShadowType = (
|
|
shadowType: AdvancedRenderingShadowType
|
|
) => {
|
|
applyAdvancedRenderingSettings(
|
|
"Set advanced rendering shadow type",
|
|
"Updated the shadow map type.",
|
|
(advancedRendering) => {
|
|
advancedRendering.shadows.type = shadowType;
|
|
}
|
|
);
|
|
};
|
|
|
|
const applyAdvancedRenderingShadowBias = () => {
|
|
try {
|
|
applyAdvancedRenderingSettings(
|
|
"Set advanced rendering shadow bias",
|
|
"Updated the shadow bias.",
|
|
(advancedRendering) => {
|
|
advancedRendering.shadows.bias = readFiniteNumberDraft(
|
|
advancedRenderingShadowBiasDraft,
|
|
"Shadow bias"
|
|
);
|
|
}
|
|
);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const applyAdvancedRenderingAmbientOcclusionEnabled = (enabled: boolean) => {
|
|
applyAdvancedRenderingSettings(
|
|
"Set ambient occlusion",
|
|
enabled ? "Ambient occlusion enabled." : "Ambient occlusion disabled.",
|
|
(advancedRendering) => {
|
|
advancedRendering.ambientOcclusion.enabled = enabled;
|
|
}
|
|
);
|
|
};
|
|
|
|
const applyAdvancedRenderingAmbientOcclusionIntensity = () => {
|
|
try {
|
|
applyAdvancedRenderingSettings(
|
|
"Set ambient occlusion intensity",
|
|
"Updated the ambient occlusion intensity.",
|
|
(advancedRendering) => {
|
|
advancedRendering.ambientOcclusion.intensity =
|
|
readNonNegativeNumberDraft(
|
|
advancedRenderingAmbientOcclusionIntensityDraft,
|
|
"Ambient occlusion intensity"
|
|
);
|
|
}
|
|
);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const applyAdvancedRenderingAmbientOcclusionRadius = () => {
|
|
try {
|
|
applyAdvancedRenderingSettings(
|
|
"Set ambient occlusion radius",
|
|
"Updated the ambient occlusion radius.",
|
|
(advancedRendering) => {
|
|
advancedRendering.ambientOcclusion.radius =
|
|
readNonNegativeNumberDraft(
|
|
advancedRenderingAmbientOcclusionRadiusDraft,
|
|
"Ambient occlusion radius"
|
|
);
|
|
}
|
|
);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const applyAdvancedRenderingAmbientOcclusionSamples = () => {
|
|
try {
|
|
applyAdvancedRenderingSettings(
|
|
"Set ambient occlusion samples",
|
|
"Updated the ambient occlusion samples.",
|
|
(advancedRendering) => {
|
|
advancedRendering.ambientOcclusion.samples = readPositiveIntegerDraft(
|
|
advancedRenderingAmbientOcclusionSamplesDraft,
|
|
"Ambient occlusion samples"
|
|
);
|
|
}
|
|
);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const applyAdvancedRenderingBloomEnabled = (enabled: boolean) => {
|
|
applyAdvancedRenderingSettings(
|
|
"Set bloom",
|
|
enabled ? "Bloom enabled." : "Bloom disabled.",
|
|
(advancedRendering) => {
|
|
advancedRendering.bloom.enabled = enabled;
|
|
}
|
|
);
|
|
};
|
|
|
|
const applyAdvancedRenderingBloomIntensity = () => {
|
|
try {
|
|
applyAdvancedRenderingSettings(
|
|
"Set bloom intensity",
|
|
"Updated the bloom intensity.",
|
|
(advancedRendering) => {
|
|
advancedRendering.bloom.intensity = readNonNegativeNumberDraft(
|
|
advancedRenderingBloomIntensityDraft,
|
|
"Bloom intensity"
|
|
);
|
|
}
|
|
);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const applyAdvancedRenderingBloomThreshold = () => {
|
|
try {
|
|
applyAdvancedRenderingSettings(
|
|
"Set bloom threshold",
|
|
"Updated the bloom threshold.",
|
|
(advancedRendering) => {
|
|
advancedRendering.bloom.threshold = readNonNegativeNumberDraft(
|
|
advancedRenderingBloomThresholdDraft,
|
|
"Bloom threshold"
|
|
);
|
|
}
|
|
);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const applyAdvancedRenderingBloomRadius = () => {
|
|
try {
|
|
applyAdvancedRenderingSettings(
|
|
"Set bloom radius",
|
|
"Updated the bloom radius.",
|
|
(advancedRendering) => {
|
|
advancedRendering.bloom.radius = readNonNegativeNumberDraft(
|
|
advancedRenderingBloomRadiusDraft,
|
|
"Bloom radius"
|
|
);
|
|
}
|
|
);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const applyAdvancedRenderingToneMappingMode = (
|
|
mode: AdvancedRenderingToneMappingMode
|
|
) => {
|
|
applyAdvancedRenderingSettings(
|
|
"Set tone mapping mode",
|
|
"Updated the tone mapping mode.",
|
|
(advancedRendering) => {
|
|
advancedRendering.toneMapping.mode = mode;
|
|
}
|
|
);
|
|
};
|
|
|
|
const applyAdvancedRenderingToneMappingExposure = () => {
|
|
try {
|
|
applyAdvancedRenderingSettings(
|
|
"Set tone mapping exposure",
|
|
"Updated the tone mapping exposure.",
|
|
(advancedRendering) => {
|
|
advancedRendering.toneMapping.exposure = readPositiveNumberDraft(
|
|
advancedRenderingToneMappingExposureDraft,
|
|
"Tone mapping exposure"
|
|
);
|
|
}
|
|
);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const applyAdvancedRenderingDepthOfFieldEnabled = (enabled: boolean) => {
|
|
applyAdvancedRenderingSettings(
|
|
"Set depth of field",
|
|
enabled ? "Depth of field enabled." : "Depth of field disabled.",
|
|
(advancedRendering) => {
|
|
advancedRendering.depthOfField.enabled = enabled;
|
|
}
|
|
);
|
|
};
|
|
|
|
const applyAdvancedRenderingDepthOfFieldFocusDistance = () => {
|
|
try {
|
|
applyAdvancedRenderingSettings(
|
|
"Set focus distance",
|
|
"Updated the focus distance.",
|
|
(advancedRendering) => {
|
|
advancedRendering.depthOfField.focusDistance =
|
|
readNonNegativeNumberDraft(
|
|
advancedRenderingDepthOfFieldFocusDistanceDraft,
|
|
"Focus distance"
|
|
);
|
|
}
|
|
);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const applyAdvancedRenderingDepthOfFieldFocalLength = () => {
|
|
try {
|
|
applyAdvancedRenderingSettings(
|
|
"Set focal length",
|
|
"Updated the focal length.",
|
|
(advancedRendering) => {
|
|
advancedRendering.depthOfField.focalLength = readPositiveNumberDraft(
|
|
advancedRenderingDepthOfFieldFocalLengthDraft,
|
|
"Focal length"
|
|
);
|
|
}
|
|
);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const applyAdvancedRenderingDepthOfFieldBokehScale = () => {
|
|
try {
|
|
applyAdvancedRenderingSettings(
|
|
"Set bokeh scale",
|
|
"Updated the bokeh scale.",
|
|
(advancedRendering) => {
|
|
advancedRendering.depthOfField.bokehScale = readPositiveNumberDraft(
|
|
advancedRenderingDepthOfFieldBokehScaleDraft,
|
|
"Bokeh scale"
|
|
);
|
|
}
|
|
);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const applyAdvancedRenderingWhiteboxBevelEnabled = (enabled: boolean) => {
|
|
applyAdvancedRenderingSettings(
|
|
"Set whitebox bevel",
|
|
enabled ? "Whitebox bevel enabled." : "Whitebox bevel disabled.",
|
|
(advancedRendering) => {
|
|
advancedRendering.whiteboxBevel.enabled = enabled;
|
|
}
|
|
);
|
|
};
|
|
|
|
const applyAdvancedRenderingWhiteboxBevelEdgeWidth = () => {
|
|
try {
|
|
applyAdvancedRenderingSettings(
|
|
"Set whitebox bevel edge width",
|
|
"Updated the whitebox bevel edge width.",
|
|
(advancedRendering) => {
|
|
advancedRendering.whiteboxBevel.edgeWidth =
|
|
readNonNegativeNumberDraft(
|
|
advancedRenderingWhiteboxBevelEdgeWidthDraft,
|
|
"Whitebox bevel edge width"
|
|
);
|
|
}
|
|
);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const applyAdvancedRenderingWhiteboxBevelNormalStrength = () => {
|
|
try {
|
|
applyAdvancedRenderingSettings(
|
|
"Set whitebox bevel normal strength",
|
|
"Updated the whitebox bevel normal strength.",
|
|
(advancedRendering) => {
|
|
advancedRendering.whiteboxBevel.normalStrength =
|
|
readNonNegativeNumberDraft(
|
|
advancedRenderingWhiteboxBevelNormalStrengthDraft,
|
|
"Whitebox bevel normal strength"
|
|
);
|
|
}
|
|
);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const applyAdvancedRenderingFogPath = (path: BoxVolumeRenderPath) => {
|
|
applyAdvancedRenderingSettings(
|
|
"Set fog render path",
|
|
`Fog render path set to ${formatBoxVolumeRenderPathLabel(path)}.`,
|
|
(advancedRendering) => {
|
|
advancedRendering.fogPath = path;
|
|
}
|
|
);
|
|
};
|
|
|
|
const applyAdvancedRenderingWaterPath = (path: BoxVolumeRenderPath) => {
|
|
applyAdvancedRenderingSettings(
|
|
"Set water render path",
|
|
`Water render path set to ${formatBoxVolumeRenderPathLabel(path)}.`,
|
|
(advancedRendering) => {
|
|
advancedRendering.waterPath = path;
|
|
}
|
|
);
|
|
};
|
|
|
|
const applyAdvancedRenderingWaterReflectionMode = (
|
|
mode: AdvancedRenderingWaterReflectionMode
|
|
) => {
|
|
applyAdvancedRenderingSettings(
|
|
"Set water reflection mode",
|
|
`Water reflection mode set to ${formatAdvancedRenderingWaterReflectionModeLabel(mode)}.`,
|
|
(advancedRendering) => {
|
|
advancedRendering.waterReflectionMode = mode;
|
|
}
|
|
);
|
|
};
|
|
|
|
const applyBrushNameChange = () => {
|
|
if (selectedBrush === null) {
|
|
setStatusMessage("Select a whitebox box before renaming it.");
|
|
return;
|
|
}
|
|
|
|
const nextName = normalizeBrushName(brushNameDraft);
|
|
|
|
if (selectedBrush.name === nextName) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
store.executeCommand(
|
|
createSetBoxBrushNameCommand({
|
|
brushId: selectedBrush.id,
|
|
name: brushNameDraft
|
|
})
|
|
);
|
|
setStatusMessage(
|
|
nextName === undefined
|
|
? "Cleared the authored brush name."
|
|
: `Renamed brush to ${nextName}.`
|
|
);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const applyPathNameChange = () => {
|
|
if (selectedPath === null) {
|
|
setStatusMessage("Select a path before renaming it.");
|
|
return;
|
|
}
|
|
|
|
const nextName = normalizeScenePathName(pathNameDraft);
|
|
|
|
if (selectedPath.name === nextName) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
store.executeCommand(
|
|
createSetPathNameCommand({
|
|
pathId: selectedPath.id,
|
|
name: pathNameDraft
|
|
})
|
|
);
|
|
setStatusMessage(
|
|
nextName === undefined
|
|
? "Cleared the authored path name."
|
|
: `Renamed path to ${nextName}.`
|
|
);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const applyEntityNameChange = () => {
|
|
if (selectedEntity === null) {
|
|
setStatusMessage("Select an entity before renaming it.");
|
|
return;
|
|
}
|
|
|
|
const nextName = normalizeEntityName(entityNameDraft);
|
|
|
|
if (selectedEntity.name === nextName) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
store.executeCommand(
|
|
createSetEntityNameCommand({
|
|
entityId: selectedEntity.id,
|
|
name: entityNameDraft
|
|
})
|
|
);
|
|
setStatusMessage(
|
|
nextName === undefined
|
|
? "Cleared the authored entity name."
|
|
: `Renamed entity to ${nextName}.`
|
|
);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const applyModelInstanceNameChange = () => {
|
|
if (selectedModelInstance === null) {
|
|
setStatusMessage("Select a model instance before renaming it.");
|
|
return;
|
|
}
|
|
|
|
const nextName = normalizeModelInstanceName(modelInstanceNameDraft);
|
|
|
|
if (selectedModelInstance.name === nextName) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
store.executeCommand(
|
|
createSetModelInstanceNameCommand({
|
|
modelInstanceId: selectedModelInstance.id,
|
|
name: modelInstanceNameDraft
|
|
})
|
|
);
|
|
setStatusMessage(
|
|
nextName === undefined
|
|
? "Cleared the authored model instance name."
|
|
: `Renamed model instance to ${nextName}.`
|
|
);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const handleInlineNameInputKeyDown = (
|
|
event: ReactKeyboardEvent<HTMLInputElement>,
|
|
resetDraft: () => void
|
|
) => {
|
|
if (event.key === "Enter") {
|
|
event.preventDefault();
|
|
event.currentTarget.blur();
|
|
return;
|
|
}
|
|
|
|
if (event.key === "Escape") {
|
|
event.preventDefault();
|
|
resetDraft();
|
|
event.currentTarget.blur();
|
|
}
|
|
};
|
|
|
|
const handleDraftVectorKeyDown = (
|
|
event: ReactKeyboardEvent<HTMLInputElement>,
|
|
applyChange: () => void
|
|
) => {
|
|
if (event.key === "Enter") {
|
|
applyChange();
|
|
}
|
|
};
|
|
|
|
const scheduleDraftCommit = (applyChange: () => void) => {
|
|
window.setTimeout(() => {
|
|
applyChange();
|
|
}, 0);
|
|
};
|
|
|
|
const handleNumberInputPointerUp = (
|
|
_event: ReactPointerEvent<HTMLInputElement>,
|
|
applyChange: () => void
|
|
) => {
|
|
scheduleDraftCommit(applyChange);
|
|
};
|
|
|
|
const handleNumberInputKeyUp = (
|
|
event: ReactKeyboardEvent<HTMLInputElement>,
|
|
applyChange: () => void
|
|
) => {
|
|
if (!isCommitIncrementKey(event.key)) {
|
|
return;
|
|
}
|
|
|
|
scheduleDraftCommit(applyChange);
|
|
};
|
|
|
|
const handleSaveProject = async () => {
|
|
try {
|
|
if (!projectAssetStorageReady && projectAssetList.length > 0) {
|
|
throw new Error(
|
|
"Project save failed: project asset storage is still initializing for this asset-backed scene."
|
|
);
|
|
}
|
|
|
|
const projectBytes = await saveProjectPackage(
|
|
editorState.projectDocument,
|
|
projectAssetStorage
|
|
);
|
|
const blobBytes = new Uint8Array(projectBytes);
|
|
const blob = new Blob([blobBytes.buffer as ArrayBuffer], {
|
|
type: "application/zip"
|
|
});
|
|
const objectUrl = URL.createObjectURL(blob);
|
|
const anchor = document.createElement("a");
|
|
|
|
anchor.href = objectUrl;
|
|
anchor.download = createProjectDownloadName(
|
|
editorState.projectDocument.name
|
|
);
|
|
anchor.click();
|
|
URL.revokeObjectURL(objectUrl);
|
|
|
|
setStatusMessage(`Saved project ${anchor.download}.`);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const handleLoadProjectButtonClick = () => {
|
|
importProjectInputRef.current?.click();
|
|
};
|
|
|
|
const handleLoadProjectChange = async (
|
|
event: ChangeEvent<HTMLInputElement>
|
|
) => {
|
|
const input = event.currentTarget;
|
|
const file = input.files?.[0];
|
|
|
|
if (file === undefined) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const projectBytes = new Uint8Array(await file.arrayBuffer());
|
|
const nextDocument = await loadProjectPackage(
|
|
projectBytes,
|
|
projectAssetStorage
|
|
);
|
|
store.replaceDocument(nextDocument);
|
|
setStatusMessage(`Loaded project ${file.name}.`);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
} finally {
|
|
input.value = "";
|
|
}
|
|
};
|
|
|
|
const handleImportModelButtonClick = () => {
|
|
importModelInputRef.current?.click();
|
|
};
|
|
|
|
const handleImportBackgroundImageButtonClick = () => {
|
|
importBackgroundImageInputRef.current?.click();
|
|
};
|
|
|
|
const handleImportAudioButtonClick = () => {
|
|
importAudioInputRef.current?.click();
|
|
};
|
|
|
|
const handleImportModelChange = async (
|
|
event: ChangeEvent<HTMLInputElement>
|
|
) => {
|
|
const input = event.currentTarget;
|
|
const files = Array.from(input.files ?? []);
|
|
|
|
if (files.length === 0) {
|
|
return;
|
|
}
|
|
|
|
if (projectAssetStorage === null) {
|
|
setAssetStatusMessage(
|
|
"Imported model assets require project asset storage. IndexedDB is unavailable in this browser."
|
|
);
|
|
input.value = "";
|
|
return;
|
|
}
|
|
|
|
let importedModelForCleanup: ImportedModelAssetResult | null = null;
|
|
|
|
try {
|
|
const importedModel =
|
|
files.length === 1
|
|
? await importModelAssetFromFile(files[0], projectAssetStorage)
|
|
: await importModelAssetFromFiles(files, projectAssetStorage);
|
|
importedModelForCleanup = importedModel;
|
|
|
|
store.executeCommand(
|
|
createImportModelAssetCommand({
|
|
asset: importedModel.asset,
|
|
modelInstance: importedModel.modelInstance,
|
|
label: `Import ${importedModel.asset.sourceName}`
|
|
})
|
|
);
|
|
|
|
loadedModelAssetsRef.current = {
|
|
...loadedModelAssetsRef.current,
|
|
[importedModel.asset.id]: importedModel.loadedAsset
|
|
};
|
|
setLoadedModelAssets((currentLoadedAssets) => ({
|
|
...currentLoadedAssets,
|
|
[importedModel.asset.id]: importedModel.loadedAsset
|
|
}));
|
|
setAssetStatusMessage(null);
|
|
setStatusMessage(
|
|
`Imported ${importedModel.asset.sourceName} and placed a model instance.`
|
|
);
|
|
} catch (error) {
|
|
if (importedModelForCleanup !== null) {
|
|
await projectAssetStorage
|
|
.deleteAsset(importedModelForCleanup.asset.storageKey)
|
|
.catch(() => undefined);
|
|
disposeModelTemplate(importedModelForCleanup.loadedAsset.template);
|
|
}
|
|
|
|
const message = getErrorMessage(error);
|
|
setStatusMessage(message);
|
|
setAssetStatusMessage(message);
|
|
} finally {
|
|
input.value = "";
|
|
}
|
|
};
|
|
|
|
const handleImportBackgroundImageChange = async (
|
|
event: ChangeEvent<HTMLInputElement>
|
|
) => {
|
|
const input = event.currentTarget;
|
|
const file = input.files?.[0];
|
|
|
|
if (file === undefined) {
|
|
return;
|
|
}
|
|
|
|
if (projectAssetStorage === null) {
|
|
setAssetStatusMessage(
|
|
"Imported background images require project asset storage. IndexedDB is unavailable in this browser."
|
|
);
|
|
input.value = "";
|
|
return;
|
|
}
|
|
|
|
let importedImageForCleanup: ImportedImageAssetResult | null = null;
|
|
|
|
try {
|
|
const importedImage = await importBackgroundImageAssetFromFile(
|
|
file,
|
|
projectAssetStorage
|
|
);
|
|
importedImageForCleanup = importedImage;
|
|
|
|
store.executeCommand(
|
|
createImportBackgroundImageAssetCommand({
|
|
asset: importedImage.asset,
|
|
world: {
|
|
...editorState.document.world,
|
|
background: changeWorldBackgroundMode(
|
|
editorState.document.world.background,
|
|
"image",
|
|
importedImage.asset.id
|
|
)
|
|
},
|
|
label: `Import ${importedImage.asset.sourceName} as background`
|
|
})
|
|
);
|
|
|
|
loadedImageAssetsRef.current = {
|
|
...loadedImageAssetsRef.current,
|
|
[importedImage.asset.id]: importedImage.loadedAsset
|
|
};
|
|
setLoadedImageAssets((currentLoadedAssets) => ({
|
|
...currentLoadedAssets,
|
|
[importedImage.asset.id]: importedImage.loadedAsset
|
|
}));
|
|
setAssetStatusMessage(null);
|
|
setStatusMessage(
|
|
`Imported ${importedImage.asset.sourceName} and set it as the world background.`
|
|
);
|
|
} catch (error) {
|
|
if (importedImageForCleanup !== null) {
|
|
await projectAssetStorage
|
|
.deleteAsset(importedImageForCleanup.asset.storageKey)
|
|
.catch(() => undefined);
|
|
disposeLoadedImageAsset(importedImageForCleanup.loadedAsset);
|
|
}
|
|
|
|
const message = getErrorMessage(error);
|
|
setStatusMessage(message);
|
|
setAssetStatusMessage(message);
|
|
} finally {
|
|
input.value = "";
|
|
}
|
|
};
|
|
|
|
const handleImportAudioChange = async (
|
|
event: ChangeEvent<HTMLInputElement>
|
|
) => {
|
|
const input = event.currentTarget;
|
|
const file = input.files?.[0];
|
|
|
|
if (file === undefined) {
|
|
return;
|
|
}
|
|
|
|
if (projectAssetStorage === null) {
|
|
setAssetStatusMessage(
|
|
"Imported audio assets require project asset storage. IndexedDB is unavailable in this browser."
|
|
);
|
|
input.value = "";
|
|
return;
|
|
}
|
|
|
|
let importedAudioForCleanup: {
|
|
asset: AudioAssetRecord;
|
|
loadedAsset: LoadedAudioAsset;
|
|
} | null = null;
|
|
|
|
try {
|
|
const importedAudio = await importAudioAssetFromFile(
|
|
file,
|
|
projectAssetStorage
|
|
);
|
|
importedAudioForCleanup = importedAudio;
|
|
|
|
store.executeCommand(
|
|
createImportAudioAssetCommand({
|
|
asset: importedAudio.asset,
|
|
label: `Import ${importedAudio.asset.sourceName}`
|
|
})
|
|
);
|
|
|
|
loadedAudioAssetsRef.current = {
|
|
...loadedAudioAssetsRef.current,
|
|
[importedAudio.asset.id]: importedAudio.loadedAsset
|
|
};
|
|
setLoadedAudioAssets((currentLoadedAssets) => ({
|
|
...currentLoadedAssets,
|
|
[importedAudio.asset.id]: importedAudio.loadedAsset
|
|
}));
|
|
setAssetStatusMessage(null);
|
|
setStatusMessage(
|
|
`Imported ${importedAudio.asset.sourceName} and registered it as an audio asset.`
|
|
);
|
|
} catch (error) {
|
|
if (importedAudioForCleanup !== null) {
|
|
await projectAssetStorage
|
|
.deleteAsset(importedAudioForCleanup.asset.storageKey)
|
|
.catch(() => undefined);
|
|
}
|
|
|
|
const message = getErrorMessage(error);
|
|
setStatusMessage(message);
|
|
setAssetStatusMessage(message);
|
|
} finally {
|
|
input.value = "";
|
|
}
|
|
};
|
|
|
|
const applyFaceMaterial = (materialId: string) => {
|
|
if (
|
|
selectedBrush !== null &&
|
|
whiteboxSelectionMode === "object" &&
|
|
materialInspectorScope === "brush"
|
|
) {
|
|
if (selectedBrushSharedMaterialId === materialId) {
|
|
setStatusMessage(
|
|
"All faces on the selected whitebox already use that material."
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
store.executeCommand(
|
|
createSetBoxBrushAllFaceMaterialsCommand({
|
|
brushId: selectedBrush.id,
|
|
materialId
|
|
})
|
|
);
|
|
setStatusMessage(
|
|
`Applied ${editorState.document.materials[materialId]?.name ?? materialId} to all faces on the selected whitebox.`
|
|
);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (
|
|
selectedBrush === null ||
|
|
selectedFaceId === null ||
|
|
selectedFace === null
|
|
) {
|
|
setStatusMessage("Select a single box face before applying a material.");
|
|
return;
|
|
}
|
|
|
|
if (selectedFace.materialId === materialId) {
|
|
setStatusMessage(
|
|
`${getBrushFaceLabel(selectedBrush, selectedFaceId)} already uses that material.`
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
store.executeCommand(
|
|
createSetBoxBrushFaceMaterialCommand({
|
|
brushId: selectedBrush.id,
|
|
faceId: selectedFaceId,
|
|
materialId
|
|
})
|
|
);
|
|
setStatusMessage(
|
|
`Applied ${editorState.document.materials[materialId]?.name ?? materialId} to ${getBrushFaceLabel(selectedBrush, selectedFaceId)}.`
|
|
);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const clearFaceMaterial = () => {
|
|
if (
|
|
selectedBrush !== null &&
|
|
whiteboxSelectionMode === "object" &&
|
|
materialInspectorScope === "brush"
|
|
) {
|
|
if (
|
|
selectedBrushSharedMaterialId === null &&
|
|
!selectedBrushHasMixedFaceMaterials
|
|
) {
|
|
setStatusMessage(
|
|
"All faces on the selected whitebox already use the fallback face material."
|
|
);
|
|
return;
|
|
}
|
|
|
|
store.executeCommand(
|
|
createSetBoxBrushAllFaceMaterialsCommand({
|
|
brushId: selectedBrush.id,
|
|
materialId: null
|
|
})
|
|
);
|
|
setStatusMessage(
|
|
"Cleared the authored material on every face of the selected whitebox."
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (
|
|
selectedBrush === null ||
|
|
selectedFaceId === null ||
|
|
selectedFace === null
|
|
) {
|
|
setStatusMessage(
|
|
"Select a single box face before clearing its material."
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (selectedFace.materialId === null) {
|
|
setStatusMessage(
|
|
`${getBrushFaceLabel(selectedBrush, selectedFaceId)} already uses the fallback face material.`
|
|
);
|
|
return;
|
|
}
|
|
|
|
store.executeCommand(
|
|
createSetBoxBrushFaceMaterialCommand({
|
|
brushId: selectedBrush.id,
|
|
faceId: selectedFaceId,
|
|
materialId: null
|
|
})
|
|
);
|
|
setStatusMessage(
|
|
`Cleared the authored material on ${getBrushFaceLabel(selectedBrush, selectedFaceId)}.`
|
|
);
|
|
};
|
|
|
|
const applyFaceUvState = (
|
|
uvState: FaceUvState,
|
|
label: string,
|
|
successMessage: string
|
|
) => {
|
|
if (
|
|
selectedBrush !== null &&
|
|
whiteboxSelectionMode === "object" &&
|
|
materialInspectorScope === "brush"
|
|
) {
|
|
const allFacesAlreadyMatch = selectedBrushFaceIds.every((faceId) =>
|
|
areFaceUvStatesEqual(selectedBrush.faces[faceId].uv, uvState)
|
|
);
|
|
|
|
if (allFacesAlreadyMatch) {
|
|
setStatusMessage("All face UVs on that whitebox are already current.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
store.executeCommand(
|
|
createUpdateBoxBrushAllFaceUvsCommand({
|
|
brushId: selectedBrush.id,
|
|
label,
|
|
updateUvState: () => uvState
|
|
})
|
|
);
|
|
setStatusMessage(successMessage);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (
|
|
selectedBrush === null ||
|
|
selectedFaceId === null ||
|
|
selectedFace === null
|
|
) {
|
|
setStatusMessage("Select a single box face before editing UVs.");
|
|
return;
|
|
}
|
|
|
|
if (areFaceUvStatesEqual(selectedFace.uv, uvState)) {
|
|
setStatusMessage("That face UV state is already current.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
store.executeCommand(
|
|
createSetBoxBrushFaceUvStateCommand({
|
|
brushId: selectedBrush.id,
|
|
faceId: selectedFaceId,
|
|
uvState,
|
|
label
|
|
})
|
|
);
|
|
setStatusMessage(successMessage);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const handleApplyUvDraft = () => {
|
|
if (
|
|
selectedBrush !== null &&
|
|
whiteboxSelectionMode === "object" &&
|
|
materialInspectorScope === "brush"
|
|
) {
|
|
try {
|
|
const offset = readVec2Draft(uvOffsetDraft, "Face UV offset");
|
|
const scale = readPositiveVec2Draft(uvScaleDraft, "Face UV scale");
|
|
|
|
store.executeCommand(
|
|
createUpdateBoxBrushAllFaceUvsCommand({
|
|
brushId: selectedBrush.id,
|
|
label: "Set solid face UV offset and scale",
|
|
updateUvState: (uvState) => ({
|
|
...uvState,
|
|
offset,
|
|
scale
|
|
})
|
|
})
|
|
);
|
|
setStatusMessage(
|
|
"Updated face UV offset and scale across the selected whitebox."
|
|
);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (selectedFace === null) {
|
|
setStatusMessage("Select a single box face before editing UVs.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
applyFaceUvState(
|
|
{
|
|
...selectedFace.uv,
|
|
offset: readVec2Draft(uvOffsetDraft, "Face UV offset"),
|
|
scale: readPositiveVec2Draft(uvScaleDraft, "Face UV scale")
|
|
},
|
|
"Set face UV offset and scale",
|
|
"Updated face UV offset and scale."
|
|
);
|
|
} catch (error) {
|
|
setStatusMessage(getErrorMessage(error));
|
|
}
|
|
};
|
|
|
|
const handleRotateUv = () => {
|
|
if (
|
|
selectedBrush !== null &&
|
|
whiteboxSelectionMode === "object" &&
|
|
materialInspectorScope === "brush"
|
|
) {
|
|
store.executeCommand(
|
|
createUpdateBoxBrushAllFaceUvsCommand({
|
|
brushId: selectedBrush.id,
|
|
label: "Rotate solid face UVs 90 degrees",
|
|
updateUvState: (uvState) => ({
|
|
...uvState,
|
|
rotationQuarterTurns: rotateQuarterTurns(
|
|
uvState.rotationQuarterTurns
|
|
)
|
|
})
|
|
})
|
|
);
|
|
setStatusMessage(
|
|
"Rotated all face UVs 90 degrees on the selected whitebox."
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (selectedFace === null) {
|
|
setStatusMessage("Select a single box face before rotating UVs.");
|
|
return;
|
|
}
|
|
|
|
applyFaceUvState(
|
|
{
|
|
...selectedFace.uv,
|
|
rotationQuarterTurns: rotateQuarterTurns(
|
|
selectedFace.uv.rotationQuarterTurns
|
|
)
|
|
},
|
|
"Rotate face UV 90 degrees",
|
|
"Rotated face UVs 90 degrees."
|
|
);
|
|
};
|
|
|
|
const handleFlipUv = (axis: "u" | "v") => {
|
|
if (
|
|
selectedBrush !== null &&
|
|
whiteboxSelectionMode === "object" &&
|
|
materialInspectorScope === "brush"
|
|
) {
|
|
store.executeCommand(
|
|
createUpdateBoxBrushAllFaceUvsCommand({
|
|
brushId: selectedBrush.id,
|
|
label: axis === "u" ? "Flip solid face UV U" : "Flip solid face UV V",
|
|
updateUvState: (uvState) => ({
|
|
...uvState,
|
|
flipU: axis === "u" ? !uvState.flipU : uvState.flipU,
|
|
flipV: axis === "v" ? !uvState.flipV : uvState.flipV
|
|
})
|
|
})
|
|
);
|
|
setStatusMessage(
|
|
axis === "u"
|
|
? "Flipped U across all faces on the selected whitebox."
|
|
: "Flipped V across all faces on the selected whitebox."
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (selectedFace === null) {
|
|
setStatusMessage("Select a single box face before flipping UVs.");
|
|
return;
|
|
}
|
|
|
|
applyFaceUvState(
|
|
{
|
|
...selectedFace.uv,
|
|
flipU: axis === "u" ? !selectedFace.uv.flipU : selectedFace.uv.flipU,
|
|
flipV: axis === "v" ? !selectedFace.uv.flipV : selectedFace.uv.flipV
|
|
},
|
|
axis === "u" ? "Flip face UV U" : "Flip face UV V",
|
|
axis === "u" ? "Flipped face UVs on U." : "Flipped face UVs on V."
|
|
);
|
|
};
|
|
|
|
const handleFitUvToFace = () => {
|
|
if (
|
|
selectedBrush !== null &&
|
|
whiteboxSelectionMode === "object" &&
|
|
materialInspectorScope === "brush"
|
|
) {
|
|
store.executeCommand(
|
|
createUpdateBoxBrushAllFaceUvsCommand({
|
|
brushId: selectedBrush.id,
|
|
label: "Fit solid face UVs to face",
|
|
updateUvState: (_, faceId) => {
|
|
const currentMaterialId = selectedBrush.faces[faceId].materialId;
|
|
const currentMaterial =
|
|
currentMaterialId === null
|
|
? null
|
|
: (editorState.document.materials[currentMaterialId] ?? null);
|
|
|
|
return currentMaterial === null
|
|
? createFitToFaceBoxBrushFaceUvState(selectedBrush, faceId)
|
|
: createFitToMaterialTileBoxBrushFaceUvState(
|
|
selectedBrush,
|
|
faceId,
|
|
getStarterMaterialTileSizeMeters(currentMaterial)
|
|
);
|
|
}
|
|
})
|
|
);
|
|
setStatusMessage(
|
|
"Fit all face UVs on the selected whitebox to their face bounds and tile size."
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (selectedBrush === null || selectedFaceId === null) {
|
|
setStatusMessage("Select a single box face before fitting UVs.");
|
|
return;
|
|
}
|
|
|
|
applyFaceUvState(
|
|
selectedFaceMaterial === undefined || selectedFaceMaterial === null
|
|
? createFitToFaceBoxBrushFaceUvState(selectedBrush, selectedFaceId)
|
|
: createFitToMaterialTileBoxBrushFaceUvState(
|
|
selectedBrush,
|
|
selectedFaceId,
|
|
getStarterMaterialTileSizeMeters(selectedFaceMaterial)
|
|
),
|
|
"Fit face UV to face",
|
|
"Fit the selected face UVs to the face bounds."
|
|
);
|
|
};
|
|
|
|
const handleEnterPlayMode = () => {
|
|
if (blockingDiagnostics.length > 0) {
|
|
setStatusMessage(
|
|
`Run mode blocked: ${formatSceneDiagnosticSummary(blockingDiagnostics)}`
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const nextRuntimeScene = buildRuntimeSceneForProjectScene(
|
|
editorState.activeSceneId
|
|
);
|
|
|
|
applyRuntimeSceneSession(editorState.activeSceneId, nextRuntimeScene);
|
|
setRuntimeMessage(null);
|
|
setFirstPersonTelemetry(null);
|
|
setRuntimeInteractionPrompt(null);
|
|
setRuntimeGlobalState(
|
|
createDefaultRuntimeGlobalState(editorState.projectDocument.time)
|
|
);
|
|
store.enterPlayMode();
|
|
setStatusMessage(
|
|
nextRuntimeScene.navigationMode === "firstPerson"
|
|
? "Entered run mode with first-person navigation."
|
|
: "Entered run mode with third-person navigation."
|
|
);
|
|
} catch (error) {
|
|
setStatusMessage(`Run mode could not start: ${getErrorMessage(error)}`);
|
|
}
|
|
};
|
|
|
|
const handleExitPlayMode = () => {
|
|
setRuntimeScene(null);
|
|
setRuntimeSceneId(null);
|
|
setRuntimeSceneName(null);
|
|
setRuntimeSceneLoadingScreen(null);
|
|
setRuntimeGlobalState(
|
|
createDefaultRuntimeGlobalState(editorState.projectDocument.time)
|
|
);
|
|
setRuntimeMessage(null);
|
|
setFirstPersonTelemetry(null);
|
|
setRuntimeInteractionPrompt(null);
|
|
setActiveNavigationMode("thirdPerson");
|
|
store.exitPlayMode();
|
|
setStatusMessage("Returned to editor mode.");
|
|
};
|
|
|
|
const createAssetMenuHoverHandler =
|
|
(assetId: string) => (hovered: boolean) => {
|
|
setHoveredAssetId((current) =>
|
|
hovered ? assetId : current === assetId ? null : current
|
|
);
|
|
};
|
|
|
|
const createDisabledMenuAction = (
|
|
label: string,
|
|
testId: string
|
|
): HierarchicalMenuItem => ({
|
|
kind: "action",
|
|
label,
|
|
testId,
|
|
disabled: true,
|
|
onSelect: () => undefined
|
|
});
|
|
|
|
const addMenuItems: HierarchicalMenuItem[] = [
|
|
{
|
|
kind: "group",
|
|
label: "Whitebox Primitives",
|
|
testId: "add-menu-whitebox-primitives",
|
|
children: [
|
|
{
|
|
kind: "action",
|
|
label: "Box",
|
|
testId: "add-menu-box",
|
|
onSelect: beginBoxCreation
|
|
},
|
|
{
|
|
kind: "action",
|
|
label: "Wedge",
|
|
testId: "add-menu-wedge",
|
|
onSelect: beginWedgeCreation
|
|
},
|
|
{
|
|
kind: "action",
|
|
label: "Cylinder",
|
|
testId: "add-menu-cylinder",
|
|
onSelect: beginCylinderCreation
|
|
},
|
|
{
|
|
kind: "action",
|
|
label: "Cone",
|
|
testId: "add-menu-cone",
|
|
onSelect: beginConeCreation
|
|
},
|
|
{
|
|
kind: "action",
|
|
label: "Torus",
|
|
testId: "add-menu-torus",
|
|
onSelect: beginTorusCreation
|
|
}
|
|
]
|
|
},
|
|
{
|
|
kind: "action",
|
|
label: "Terrain",
|
|
testId: "add-menu-terrain",
|
|
onSelect: handleCreateTerrain
|
|
},
|
|
{
|
|
kind: "action",
|
|
label: "Path",
|
|
testId: "add-menu-path",
|
|
onSelect: handleCreatePath
|
|
},
|
|
{
|
|
kind: "group",
|
|
label: "Entities",
|
|
testId: "add-menu-entities",
|
|
children: [
|
|
{
|
|
kind: "action",
|
|
label: "Camera Rig",
|
|
testId: "add-menu-camera-rig",
|
|
onSelect: () => beginEntityCreation("cameraRig")
|
|
},
|
|
{
|
|
kind: "action",
|
|
label: "Player Start",
|
|
testId: "add-menu-player-start",
|
|
onSelect: () => beginEntityCreation("playerStart")
|
|
},
|
|
{
|
|
kind: "action",
|
|
label: "Scene Entry",
|
|
testId: "add-menu-scene-entry",
|
|
onSelect: () => beginEntityCreation("sceneEntry")
|
|
},
|
|
{
|
|
kind: "action",
|
|
label: "NPC",
|
|
testId: "add-menu-npc",
|
|
onSelect: () =>
|
|
beginEntityCreation("npc", {
|
|
modelAssetId: modelAssetList[0]?.id ?? null
|
|
})
|
|
},
|
|
{
|
|
kind: "action",
|
|
label: "Sound Emitter",
|
|
testId: "add-menu-sound-emitter",
|
|
onSelect: () =>
|
|
beginEntityCreation("soundEmitter", {
|
|
audioAssetId: audioAssetList[0]?.id ?? null
|
|
})
|
|
},
|
|
{
|
|
kind: "action",
|
|
label: "Trigger Volume",
|
|
testId: "add-menu-trigger-volume",
|
|
onSelect: () => beginEntityCreation("triggerVolume")
|
|
},
|
|
{
|
|
kind: "action",
|
|
label: "Teleport Target",
|
|
testId: "add-menu-teleport-target",
|
|
onSelect: () => beginEntityCreation("teleportTarget")
|
|
},
|
|
{
|
|
kind: "action",
|
|
label: "Interactable",
|
|
testId: "add-menu-interactable",
|
|
onSelect: () => beginEntityCreation("interactable")
|
|
}
|
|
]
|
|
},
|
|
{
|
|
kind: "group",
|
|
label: "Lights",
|
|
testId: "add-menu-lights",
|
|
children: [
|
|
{
|
|
kind: "action",
|
|
label: "Point Light",
|
|
testId: "add-menu-point-light",
|
|
onSelect: () => beginEntityCreation("pointLight")
|
|
},
|
|
{
|
|
kind: "action",
|
|
label: "Spot Light",
|
|
testId: "add-menu-spot-light",
|
|
onSelect: () => beginEntityCreation("spotLight")
|
|
}
|
|
]
|
|
},
|
|
{
|
|
kind: "group",
|
|
label: "Assets",
|
|
testId: "add-menu-assets",
|
|
children: [
|
|
{
|
|
kind: "group",
|
|
label: "3D Models",
|
|
testId: "add-menu-assets-models",
|
|
children:
|
|
modelAssetList.length === 0
|
|
? [
|
|
createDisabledMenuAction(
|
|
"No imported 3D models",
|
|
"add-menu-assets-models-empty"
|
|
)
|
|
]
|
|
: modelAssetList.map((asset) => ({
|
|
kind: "action" as const,
|
|
label: asset.sourceName,
|
|
testId: `add-menu-model-asset-${asset.id}`,
|
|
onSelect: () => beginModelInstanceCreation(asset.id),
|
|
onHoverChange: createAssetMenuHoverHandler(asset.id)
|
|
}))
|
|
},
|
|
{
|
|
kind: "group",
|
|
label: "Environments",
|
|
testId: "add-menu-assets-environments",
|
|
children:
|
|
imageAssetList.length === 0
|
|
? [
|
|
createDisabledMenuAction(
|
|
"No imported environments",
|
|
"add-menu-assets-environments-empty"
|
|
)
|
|
]
|
|
: imageAssetList.map((asset) => ({
|
|
kind: "action" as const,
|
|
label: asset.sourceName,
|
|
testId: `add-menu-image-asset-${asset.id}`,
|
|
onSelect: () => applyWorldBackgroundMode("image", asset.id),
|
|
onHoverChange: createAssetMenuHoverHandler(asset.id)
|
|
}))
|
|
},
|
|
{
|
|
kind: "group",
|
|
label: "Audio",
|
|
testId: "add-menu-assets-audio",
|
|
children:
|
|
audioAssetList.length === 0
|
|
? [
|
|
createDisabledMenuAction(
|
|
"No imported audio",
|
|
"add-menu-assets-audio-empty"
|
|
)
|
|
]
|
|
: audioAssetList.map((asset) => ({
|
|
kind: "action" as const,
|
|
label: asset.sourceName,
|
|
testId: `add-menu-audio-asset-${asset.id}`,
|
|
onSelect: () =>
|
|
beginEntityCreation("soundEmitter", {
|
|
audioAssetId: asset.id
|
|
}),
|
|
onHoverChange: createAssetMenuHoverHandler(asset.id)
|
|
}))
|
|
}
|
|
]
|
|
},
|
|
{
|
|
kind: "group",
|
|
label: "Import",
|
|
testId: "add-menu-import",
|
|
children: [
|
|
{
|
|
kind: "action",
|
|
label: "3D Model (GLB/GLTF)",
|
|
testId: "import-menu-model",
|
|
disabled: !projectAssetStorageReady || projectAssetStorage === null,
|
|
onSelect: handleImportModelButtonClick
|
|
},
|
|
{
|
|
kind: "action",
|
|
label: "Environment",
|
|
testId: "import-menu-environment",
|
|
disabled: !projectAssetStorageReady || projectAssetStorage === null,
|
|
onSelect: handleImportBackgroundImageButtonClick
|
|
},
|
|
{
|
|
kind: "action",
|
|
label: "Audio",
|
|
testId: "import-menu-audio",
|
|
disabled: !projectAssetStorageReady || projectAssetStorage === null,
|
|
onSelect: handleImportAudioButtonClick
|
|
}
|
|
]
|
|
}
|
|
];
|
|
|
|
const viewportPanelsStyle =
|
|
layoutMode === "quad"
|
|
? createViewportQuadPanelsStyle(editorState.viewportQuadSplit)
|
|
: undefined;
|
|
|
|
if (editorState.toolMode === "play" && runtimeScene !== null) {
|
|
const runtimeInteractKeyboardBinding =
|
|
runtimeScene.playerInputBindings.keyboard.interact;
|
|
const runtimeInteractKeyboardInstruction = isPlayerStartMouseBindingCode(
|
|
runtimeInteractKeyboardBinding
|
|
)
|
|
? `Click ${formatPlayerStartKeyboardBindingLabel(runtimeInteractKeyboardBinding)}`
|
|
: `Press ${formatPlayerStartKeyboardBindingLabel(runtimeInteractKeyboardBinding)}`;
|
|
const runtimeInteractGamepadInstruction = `press ${formatPlayerStartGamepadActionBindingLabel(
|
|
runtimeScene.playerInputBindings.gamepad.interact
|
|
)}`;
|
|
const runtimeInteractInstruction = `${runtimeInteractKeyboardInstruction} or ${runtimeInteractGamepadInstruction}`;
|
|
|
|
return (
|
|
<div className="app-shell app-shell--play">
|
|
<header className="toolbar">
|
|
<div className="toolbar__brand">
|
|
<div className="toolbar__title">WebEditor3D</div>
|
|
<div className="toolbar__subtitle">
|
|
Slice 3.1 GLB/GLTF import and unified creation
|
|
</div>
|
|
</div>
|
|
|
|
<div className="toolbar__actions">
|
|
<div className="toolbar__group">
|
|
<button
|
|
className="toolbar__button toolbar__button--accent"
|
|
type="button"
|
|
data-testid="exit-run-mode"
|
|
onClick={handleExitPlayMode}
|
|
>
|
|
Return To Editor
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<div className="runner-workspace">
|
|
<main className="runner-region">
|
|
<RunnerCanvas
|
|
runtimeScene={runtimeScene}
|
|
sceneName={runtimeSceneName ?? activeProjectScene.name}
|
|
sceneLoadingScreen={
|
|
runtimeSceneLoadingScreen ?? activeProjectScene.loadingScreen
|
|
}
|
|
projectAssets={editorState.document.assets}
|
|
loadedModelAssets={loadedModelAssets}
|
|
loadedImageAssets={loadedImageAssets}
|
|
loadedAudioAssets={loadedAudioAssets}
|
|
navigationMode={activeNavigationMode}
|
|
runtimeClock={runtimeGlobalState.clock}
|
|
onRuntimeMessageChange={setRuntimeMessage}
|
|
onRuntimeClockChange={handleRuntimeClockChange}
|
|
onFirstPersonTelemetryChange={setFirstPersonTelemetry}
|
|
onInteractionPromptChange={setRuntimeInteractionPrompt}
|
|
onSceneTransitionActivated={handleRunnerSceneTransitionActivated}
|
|
/>
|
|
</main>
|
|
|
|
<aside className="side-column">
|
|
<Panel title="Runner">
|
|
<div className="stat-grid">
|
|
<div className="stat-card">
|
|
<div className="label">Scene</div>
|
|
<div className="value" data-testid="runner-scene-name">
|
|
{runtimeSceneName ?? activeProjectScene.name}
|
|
</div>
|
|
</div>
|
|
<div className="stat-card">
|
|
<div className="label">Navigation</div>
|
|
<div className="value">
|
|
{activeNavigationMode === "firstPerson"
|
|
? "First Person"
|
|
: "Third Person"}
|
|
</div>
|
|
</div>
|
|
<div className="stat-card">
|
|
<div className="label">Spawn Source</div>
|
|
<div className="value">
|
|
{runtimeScene.spawn.source === "playerStart"
|
|
? "Player Start"
|
|
: runtimeScene.spawn.source === "sceneEntry"
|
|
? "Scene Entry"
|
|
: "Fallback"}
|
|
</div>
|
|
</div>
|
|
<div className="stat-card">
|
|
<div className="label">Transitions</div>
|
|
<div className="value" data-testid="runner-transition-count">
|
|
{runtimeGlobalState.transitionCount}
|
|
</div>
|
|
</div>
|
|
<div className="stat-card">
|
|
<div className="label">Project Time</div>
|
|
<div className="value" data-testid="runner-project-time">
|
|
{formatTimeOfDayHours(
|
|
runtimeGlobalState.clock.timeOfDayHours
|
|
)}
|
|
</div>
|
|
<div className="material-summary">
|
|
Day {runtimeGlobalState.clock.dayCount + 1} ·{" "}
|
|
{Number.isInteger(runtimeGlobalState.clock.dayLengthMinutes)
|
|
? runtimeGlobalState.clock.dayLengthMinutes.toFixed(0)
|
|
: runtimeGlobalState.clock.dayLengthMinutes.toFixed(
|
|
1
|
|
)}{" "}
|
|
min/day
|
|
</div>
|
|
</div>
|
|
<div className="stat-card">
|
|
<div className="label">Pointer Lock</div>
|
|
<div className="value">
|
|
{activeNavigationMode === "firstPerson"
|
|
? firstPersonTelemetry?.pointerLocked
|
|
? "active"
|
|
: "idle"
|
|
: "not used"}
|
|
</div>
|
|
</div>
|
|
<div className="stat-card">
|
|
<div className="label">Grounded</div>
|
|
<div className="value">
|
|
{firstPersonTelemetry === null
|
|
? "n/a"
|
|
: firstPersonTelemetry.grounded
|
|
? "yes"
|
|
: "no"}
|
|
</div>
|
|
</div>
|
|
<div className="stat-card">
|
|
<div className="label">Locomotion</div>
|
|
<div className="value">
|
|
{formatRunnerLocomotionMode(
|
|
firstPersonTelemetry?.locomotionState
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="stat-card">
|
|
<div className="label">Gait</div>
|
|
<div className="value">
|
|
{formatRunnerGait(firstPersonTelemetry?.locomotionState)}
|
|
</div>
|
|
</div>
|
|
<div className="stat-card">
|
|
<div className="label">Airborne</div>
|
|
<div className="value">
|
|
{formatRunnerAirborneKind(
|
|
firstPersonTelemetry?.locomotionState
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="stat-card">
|
|
<div className="label">Planar Speed</div>
|
|
<div className="value">
|
|
{firstPersonTelemetry === null
|
|
? "n/a"
|
|
: `${firstPersonTelemetry.locomotionState.planarSpeed.toFixed(2)} m/s`}
|
|
</div>
|
|
</div>
|
|
<div className="stat-card">
|
|
<div className="label">Ground Contact</div>
|
|
<div className="value">
|
|
{formatRunnerGroundContact(
|
|
firstPersonTelemetry?.locomotionState
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="stat-card">
|
|
<div className="label">Water Volume</div>
|
|
<div className="value">
|
|
{firstPersonTelemetry === null
|
|
? "n/a"
|
|
: firstPersonTelemetry.inWaterVolume
|
|
? "inside"
|
|
: "outside"}
|
|
</div>
|
|
</div>
|
|
<div className="stat-card">
|
|
<div className="label">Fog Volume</div>
|
|
<div className="value">
|
|
{firstPersonTelemetry === null
|
|
? "n/a"
|
|
: firstPersonTelemetry.inFogVolume
|
|
? "inside"
|
|
: "outside"}
|
|
</div>
|
|
</div>
|
|
<div className="stat-card">
|
|
<div className="label">Signals</div>
|
|
<div className="value">
|
|
{formatRunnerMovementSignals(firstPersonTelemetry)}
|
|
</div>
|
|
</div>
|
|
<div className="stat-card">
|
|
<div className="label">Audio Hook</div>
|
|
<div className="value">
|
|
{formatRunnerAudioHook(firstPersonTelemetry)}
|
|
</div>
|
|
</div>
|
|
<div className="stat-card">
|
|
<div className="label">Animation Hook</div>
|
|
<div className="value">
|
|
{formatRunnerAnimationHook(firstPersonTelemetry)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="stat-card">
|
|
<div className="label">Player Feet Position</div>
|
|
<div className="value" data-testid="runner-player-position">
|
|
{formatRunnerFeetPosition(
|
|
firstPersonTelemetry?.feetPosition ??
|
|
runtimeScene.spawn.position
|
|
)}
|
|
</div>
|
|
<div
|
|
className="material-summary"
|
|
data-testid="runner-spawn-state"
|
|
>
|
|
Spawn:{" "}
|
|
{runtimeScene.spawn.source === "playerStart"
|
|
? "Player Start"
|
|
: runtimeScene.spawn.source === "sceneEntry"
|
|
? "Scene Entry"
|
|
: "Fallback"}{" "}
|
|
at {formatRunnerFeetPosition(runtimeScene.spawn.position)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="stat-card">
|
|
<div className="label">Interaction</div>
|
|
<div className="value" data-testid="runner-interaction-state">
|
|
{runtimeInteractionPrompt === null ? "No target" : "Ready"}
|
|
</div>
|
|
<div
|
|
className="material-summary"
|
|
data-testid="runner-interaction-summary"
|
|
>
|
|
{runtimeInteractionPrompt === null
|
|
? `Aim at an authored Interactable or NPC. ${runtimeInteractInstruction} when a prompt appears.`
|
|
: `${runtimeInteractInstruction} to activate "${runtimeInteractionPrompt.prompt}" within ${runtimeInteractionPrompt.range.toFixed(1)}m.`}
|
|
</div>
|
|
</div>
|
|
|
|
{runtimeMessage === null ? null : (
|
|
<div className="info-banner">{runtimeMessage}</div>
|
|
)}
|
|
{runtimeGlobalState.lastSceneTransition === null ? null : (
|
|
<div className="info-banner">
|
|
Last transition:{" "}
|
|
{runtimeGlobalState.lastSceneTransition.fromSceneName} to{" "}
|
|
{runtimeGlobalState.lastSceneTransition.toSceneName}
|
|
</div>
|
|
)}
|
|
{
|
|
<div
|
|
className="info-banner"
|
|
data-testid="runner-interaction-help"
|
|
>
|
|
Interact binding: {runtimeInteractInstruction}.
|
|
</div>
|
|
}
|
|
</Panel>
|
|
</aside>
|
|
</div>
|
|
|
|
<footer className="status-bar">
|
|
<div
|
|
className="status-bar__item status-bar__item--message"
|
|
title={statusMessage}
|
|
>
|
|
<span className="status-bar__strong">Status:</span> {statusMessage}
|
|
</div>
|
|
<div className="status-bar__item">
|
|
<span className="status-bar__strong">Spawn:</span>{" "}
|
|
{runtimeScene.spawn.source === "playerStart"
|
|
? "Authored Player Start"
|
|
: runtimeScene.spawn.source === "sceneEntry"
|
|
? "Scene Entry arrival"
|
|
: "Fallback runtime spawn"}
|
|
</div>
|
|
</footer>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="app-shell">
|
|
<header className="toolbar">
|
|
<div className="toolbar__scene-controls">
|
|
<label className="toolbar__project-name">
|
|
<span className="visually-hidden">Project Name</span>
|
|
<input
|
|
data-testid="toolbar-project-name"
|
|
className="text-input toolbar__project-name-input"
|
|
type="text"
|
|
value={projectNameDraft}
|
|
onChange={(event) =>
|
|
setProjectNameDraft(event.currentTarget.value)
|
|
}
|
|
onBlur={applyProjectName}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter") {
|
|
applyProjectName();
|
|
}
|
|
}}
|
|
/>
|
|
</label>
|
|
<label className="toolbar__scene-picker">
|
|
<span className="visually-hidden">Active Scene</span>
|
|
<select
|
|
data-testid="toolbar-scene-select"
|
|
className="select-input toolbar__scene-select"
|
|
value={editorState.activeSceneId}
|
|
onChange={handleActiveSceneChange}
|
|
>
|
|
{sceneList.map((scene) => (
|
|
<option key={scene.id} value={scene.id}>
|
|
{scene.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<button
|
|
className="toolbar__button"
|
|
type="button"
|
|
data-testid="toolbar-new-scene"
|
|
onClick={handleCreateScene}
|
|
>
|
|
New Scene
|
|
</button>
|
|
<label className="toolbar__scene-name">
|
|
<span className="visually-hidden">Scene Name</span>
|
|
<input
|
|
data-testid="toolbar-scene-name"
|
|
className="text-input toolbar__scene-name-input"
|
|
type="text"
|
|
value={sceneNameDraft}
|
|
onChange={(event) => setSceneNameDraft(event.currentTarget.value)}
|
|
onBlur={applySceneName}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter") {
|
|
applySceneName();
|
|
}
|
|
}}
|
|
/>
|
|
</label>
|
|
<div className="toolbar__group">
|
|
<button
|
|
className="toolbar__button"
|
|
type="button"
|
|
data-testid="save-project-button"
|
|
onClick={() => void handleSaveProject()}
|
|
>
|
|
Save Project
|
|
</button>
|
|
<button
|
|
className="toolbar__button"
|
|
type="button"
|
|
data-testid="load-project-button"
|
|
onClick={handleLoadProjectButtonClick}
|
|
>
|
|
Load Project
|
|
</button>
|
|
<button
|
|
className={`toolbar__button toolbar__button--accent ${blockingDiagnostics.length > 0 ? "toolbar__button--warn" : ""}`}
|
|
type="button"
|
|
data-testid="enter-run-mode"
|
|
onClick={handleEnterPlayMode}
|
|
>
|
|
Run Scene
|
|
</button>
|
|
</div>
|
|
|
|
<div className="toolbar__group">
|
|
<button
|
|
className="toolbar__button"
|
|
type="button"
|
|
data-testid="toolbar-undo-button"
|
|
disabled={!editorState.canUndo}
|
|
onClick={handleUndo}
|
|
>
|
|
Undo
|
|
</button>
|
|
<button
|
|
className="toolbar__button"
|
|
type="button"
|
|
data-testid="toolbar-redo-button"
|
|
disabled={!editorState.canRedo}
|
|
onClick={handleRedo}
|
|
>
|
|
Redo
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<div className="workspace">
|
|
<aside className="side-column">
|
|
<Panel title="Outliner">
|
|
{assetStatusMessage === null ? null : (
|
|
<div className="info-banner" data-testid="asset-status-message">
|
|
{assetStatusMessage}
|
|
</div>
|
|
)}
|
|
|
|
{projectAssetStorageReady && projectAssetStorage === null ? (
|
|
<div className="outliner-empty">
|
|
Project asset storage is unavailable. Imported assets cannot be
|
|
persisted.
|
|
</div>
|
|
) : null}
|
|
<div className="outliner-section">
|
|
<div className="label">Whitebox Solids</div>
|
|
{brushList.length === 0 ? (
|
|
<div className="outliner-empty">
|
|
Use Add > Whitebox Primitives > Box and click in the
|
|
viewport to create the first solid.
|
|
</div>
|
|
) : (
|
|
<div
|
|
className="outliner-list"
|
|
data-testid="outliner-brush-list"
|
|
>
|
|
{brushList.map((brush, brushIndex) => {
|
|
const label = getBrushLabel(brush, brushIndex);
|
|
const isSelected = selectedBrush?.id === brush.id;
|
|
const isActiveSelection = isSelectionActiveId(
|
|
editorState.selection,
|
|
editorState.activeSelectionId,
|
|
brush.id
|
|
);
|
|
const authoredStateSummary =
|
|
formatAuthoredObjectStateSummary(brush);
|
|
|
|
return (
|
|
<div
|
|
key={brush.id}
|
|
className={`outliner-item outliner-item--compact ${isBrushSelected(editorState.selection, brush.id) ? "outliner-item--selected" : ""} ${isActiveSelection ? "outliner-item--active" : ""} ${brush.enabled ? "" : "outliner-item--disabled"}`}
|
|
>
|
|
<div className="outliner-item__row">
|
|
<input
|
|
className="outliner-item__toggle"
|
|
data-testid={`outliner-enable-brush-${brush.id}`}
|
|
type="checkbox"
|
|
checked={brush.enabled}
|
|
aria-label={`${brush.enabled ? "Disable" : "Enable"} ${label}`}
|
|
onChange={(event) =>
|
|
handleSetBrushEnabled(
|
|
brush,
|
|
event.currentTarget.checked
|
|
)
|
|
}
|
|
/>
|
|
{isSelected ? (
|
|
<input
|
|
className="outliner-item__rename"
|
|
data-testid="selected-brush-name"
|
|
type="text"
|
|
value={brushNameDraft}
|
|
placeholder={`Whitebox Box ${brushIndex + 1}`}
|
|
onChange={(event) =>
|
|
setBrushNameDraft(event.currentTarget.value)
|
|
}
|
|
onBlur={applyBrushNameChange}
|
|
onFocus={(event) => event.currentTarget.select()}
|
|
onKeyDown={(event) =>
|
|
handleInlineNameInputKeyDown(event, () => {
|
|
setBrushNameDraft(selectedBrush?.name ?? "");
|
|
})
|
|
}
|
|
/>
|
|
) : (
|
|
<button
|
|
className="outliner-item__select"
|
|
type="button"
|
|
data-testid={`outliner-brush-${brush.id}`}
|
|
onClick={(event) =>
|
|
applySelection(
|
|
applySameKindSelectionClick(
|
|
editorState.selection,
|
|
{
|
|
kind: "brushes",
|
|
ids: [brush.id]
|
|
},
|
|
event.shiftKey
|
|
),
|
|
"outliner",
|
|
{
|
|
focusViewport: true
|
|
}
|
|
)
|
|
}
|
|
>
|
|
<span className="outliner-item__title">
|
|
{label}
|
|
</span>
|
|
</button>
|
|
)}
|
|
<button
|
|
className="outliner-item__delete"
|
|
type="button"
|
|
data-testid={`outliner-delete-brush-${brush.id}`}
|
|
aria-label={`Delete ${label}`}
|
|
onClick={() => handleDeleteBrush(brush.id)}
|
|
>
|
|
x
|
|
</button>
|
|
</div>
|
|
{authoredStateSummary === null ? null : (
|
|
<div className="outliner-item__meta">
|
|
{authoredStateSummary}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="outliner-section">
|
|
<div className="label">Terrain</div>
|
|
{terrainList.length === 0 ? (
|
|
<div className="outliner-empty">
|
|
Use Add > Terrain to create the first authored terrain.
|
|
</div>
|
|
) : (
|
|
<div
|
|
className="outliner-list"
|
|
data-testid="outliner-terrain-list"
|
|
>
|
|
{terrainList.map((terrain, terrainIndex) => {
|
|
const label = getTerrainLabel(terrain, terrainIndex);
|
|
const authoredStateSummary =
|
|
formatAuthoredObjectStateSummary(terrain);
|
|
const isActiveSelection = isSelectionActiveId(
|
|
editorState.selection,
|
|
editorState.activeSelectionId,
|
|
terrain.id
|
|
);
|
|
|
|
return (
|
|
<div
|
|
key={terrain.id}
|
|
className={`outliner-item outliner-item--compact ${isTerrainSelected(editorState.selection, terrain.id) ? "outliner-item--selected" : ""} ${isActiveSelection ? "outliner-item--active" : ""} ${terrain.enabled ? "" : "outliner-item--disabled"}`}
|
|
>
|
|
<div className="outliner-item__row">
|
|
<button
|
|
className="outliner-item__select"
|
|
type="button"
|
|
data-testid={`outliner-terrain-${terrain.id}`}
|
|
onClick={() =>
|
|
applySelection(
|
|
{
|
|
kind: "terrains",
|
|
ids: [terrain.id]
|
|
},
|
|
"outliner",
|
|
{
|
|
focusViewport: true
|
|
}
|
|
)
|
|
}
|
|
>
|
|
<span className="outliner-item__title">
|
|
{label}
|
|
</span>
|
|
</button>
|
|
<button
|
|
className="outliner-item__delete"
|
|
type="button"
|
|
data-testid={`outliner-delete-terrain-${terrain.id}`}
|
|
aria-label={`Delete ${label}`}
|
|
onClick={() => handleDeleteTerrain(terrain.id)}
|
|
>
|
|
x
|
|
</button>
|
|
</div>
|
|
<div className="outliner-item__meta">
|
|
{[
|
|
authoredStateSummary,
|
|
`${terrain.sampleCountX} x ${terrain.sampleCountZ} samples`,
|
|
`${terrain.cellSize}m cells`
|
|
]
|
|
.filter((part): part is string => part !== null)
|
|
.join(" | ")}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="outliner-section">
|
|
<div className="label">Paths</div>
|
|
{pathList.length === 0 ? (
|
|
<div className="outliner-empty">
|
|
Use Add > Path to author the first shared motion path.
|
|
</div>
|
|
) : (
|
|
<div className="outliner-list" data-testid="outliner-path-list">
|
|
{pathList.map((path, pathIndex) => {
|
|
const label = getScenePathLabel(path, pathIndex);
|
|
const isSelected = isPathSelected(
|
|
editorState.selection,
|
|
path.id
|
|
);
|
|
const authoredStateSummary =
|
|
formatAuthoredObjectStateSummary(path);
|
|
|
|
return (
|
|
<div
|
|
key={path.id}
|
|
className={`outliner-item outliner-item--compact ${isPathSelected(editorState.selection, path.id) ? "outliner-item--selected" : ""} ${path.enabled ? "" : "outliner-item--disabled"}`}
|
|
>
|
|
<div className="outliner-item__row">
|
|
<input
|
|
className="outliner-item__toggle"
|
|
data-testid={`outliner-enable-path-${path.id}`}
|
|
type="checkbox"
|
|
checked={path.enabled}
|
|
aria-label={`${path.enabled ? "Disable" : "Enable"} ${label}`}
|
|
onChange={(event) =>
|
|
handleSetPathEnabled(
|
|
path,
|
|
event.currentTarget.checked
|
|
)
|
|
}
|
|
/>
|
|
{isSelected ? (
|
|
<input
|
|
className="outliner-item__rename"
|
|
data-testid="selected-path-name"
|
|
type="text"
|
|
value={pathNameDraft}
|
|
placeholder={`Path ${pathIndex + 1}`}
|
|
onChange={(event) =>
|
|
setPathNameDraft(event.currentTarget.value)
|
|
}
|
|
onBlur={applyPathNameChange}
|
|
onFocus={(event) => event.currentTarget.select()}
|
|
onKeyDown={(event) =>
|
|
handleInlineNameInputKeyDown(event, () => {
|
|
setPathNameDraft(selectedPath?.name ?? "");
|
|
})
|
|
}
|
|
/>
|
|
) : (
|
|
<button
|
|
className="outliner-item__select"
|
|
type="button"
|
|
data-testid={`outliner-path-${path.id}`}
|
|
onClick={() =>
|
|
applySelection(
|
|
{
|
|
kind: "paths",
|
|
ids: [path.id]
|
|
},
|
|
"outliner",
|
|
{
|
|
focusViewport: true
|
|
}
|
|
)
|
|
}
|
|
>
|
|
<span className="outliner-item__title">
|
|
{label}
|
|
</span>
|
|
</button>
|
|
)}
|
|
<button
|
|
className="outliner-item__delete"
|
|
type="button"
|
|
data-testid={`outliner-delete-path-${path.id}`}
|
|
aria-label={`Delete ${label}`}
|
|
onClick={() => handleDeletePath(path.id)}
|
|
>
|
|
x
|
|
</button>
|
|
</div>
|
|
<div className="outliner-item__meta">
|
|
{[
|
|
authoredStateSummary,
|
|
path.loop ? "Looping" : null,
|
|
`${path.points.length} point${path.points.length === 1 ? "" : "s"}`
|
|
]
|
|
.filter((part): part is string => part !== null)
|
|
.join(" | ")}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="outliner-section">
|
|
<div className="label">Model Instances</div>
|
|
{modelInstanceDisplayList.length === 0 ? (
|
|
<div className="outliner-empty">
|
|
No model instances placed yet.
|
|
</div>
|
|
) : (
|
|
<div
|
|
className="outliner-list"
|
|
data-testid="outliner-model-instance-list"
|
|
>
|
|
{modelInstanceDisplayList.map(({ modelInstance, label }) => {
|
|
const isSelected =
|
|
editorState.selection.kind === "modelInstances" &&
|
|
editorState.selection.ids.includes(modelInstance.id);
|
|
const isActiveSelection = isSelectionActiveId(
|
|
editorState.selection,
|
|
editorState.activeSelectionId,
|
|
modelInstance.id
|
|
);
|
|
const authoredStateSummary =
|
|
formatAuthoredObjectStateSummary(modelInstance);
|
|
|
|
return (
|
|
<div
|
|
key={modelInstance.id}
|
|
className={`outliner-item ${isSelected ? "outliner-item--selected" : ""} ${isActiveSelection ? "outliner-item--active" : ""} outliner-item--compact ${modelInstance.enabled ? "" : "outliner-item--disabled"}`}
|
|
>
|
|
<div className="outliner-item__row">
|
|
<input
|
|
className="outliner-item__toggle"
|
|
data-testid={`outliner-enable-model-instance-${modelInstance.id}`}
|
|
type="checkbox"
|
|
checked={modelInstance.enabled}
|
|
aria-label={`${modelInstance.enabled ? "Disable" : "Enable"} ${label}`}
|
|
onChange={(event) =>
|
|
handleSetModelInstanceEnabled(
|
|
modelInstance,
|
|
event.currentTarget.checked
|
|
)
|
|
}
|
|
/>
|
|
{isSelected ? (
|
|
<input
|
|
className="outliner-item__rename"
|
|
data-testid="selected-model-instance-name"
|
|
type="text"
|
|
value={modelInstanceNameDraft}
|
|
placeholder={
|
|
editorState.document.assets[
|
|
modelInstance.assetId
|
|
]?.sourceName ?? "Model Instance"
|
|
}
|
|
onChange={(event) =>
|
|
setModelInstanceNameDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={applyModelInstanceNameChange}
|
|
onFocus={(event) => event.currentTarget.select()}
|
|
onKeyDown={(event) =>
|
|
handleInlineNameInputKeyDown(event, () => {
|
|
setModelInstanceNameDraft(
|
|
selectedModelInstance?.name ?? ""
|
|
);
|
|
})
|
|
}
|
|
/>
|
|
) : (
|
|
<button
|
|
data-testid={`outliner-model-instance-${modelInstance.id}`}
|
|
className="outliner-item__select"
|
|
type="button"
|
|
onClick={(event) =>
|
|
applySelection(
|
|
applySameKindSelectionClick(
|
|
editorState.selection,
|
|
{
|
|
kind: "modelInstances",
|
|
ids: [modelInstance.id]
|
|
},
|
|
event.shiftKey
|
|
),
|
|
"outliner",
|
|
{
|
|
focusViewport: true
|
|
}
|
|
)
|
|
}
|
|
>
|
|
<span className="outliner-item__title">
|
|
{label}
|
|
</span>
|
|
</button>
|
|
)}
|
|
<button
|
|
className="outliner-item__delete"
|
|
type="button"
|
|
data-testid={`outliner-delete-model-instance-${modelInstance.id}`}
|
|
aria-label={`Delete ${label}`}
|
|
onClick={() =>
|
|
handleDeleteModelInstance(modelInstance.id)
|
|
}
|
|
>
|
|
x
|
|
</button>
|
|
</div>
|
|
{authoredStateSummary === null ? null : (
|
|
<div className="outliner-item__meta">
|
|
{authoredStateSummary}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="outliner-section">
|
|
<div className="label">Entities</div>
|
|
|
|
{entityDisplayList.length === 0 ? (
|
|
<div className="outliner-empty">No entities authored yet.</div>
|
|
) : null}
|
|
|
|
{entityDisplayList.length === 0 ? null : (
|
|
<div className="outliner-list">
|
|
{entityDisplayList.map(({ entity, label }) => {
|
|
const isSelected =
|
|
editorState.selection.kind === "entities" &&
|
|
editorState.selection.ids.includes(entity.id);
|
|
const isActiveSelection = isSelectionActiveId(
|
|
editorState.selection,
|
|
editorState.activeSelectionId,
|
|
entity.id
|
|
);
|
|
const authoredStateSummary =
|
|
formatAuthoredObjectStateSummary(entity);
|
|
|
|
return (
|
|
<div
|
|
key={entity.id}
|
|
className={`outliner-item ${isSelected ? "outliner-item--selected" : ""} ${isActiveSelection ? "outliner-item--active" : ""} outliner-item--compact ${entity.enabled ? "" : "outliner-item--disabled"}`}
|
|
>
|
|
<div className="outliner-item__row">
|
|
<input
|
|
className="outliner-item__toggle"
|
|
data-testid={`outliner-enable-entity-${entity.id}`}
|
|
type="checkbox"
|
|
checked={entity.enabled}
|
|
aria-label={`${entity.enabled ? "Disable" : "Enable"} ${label}`}
|
|
onChange={(event) =>
|
|
handleSetEntityEnabled(
|
|
entity,
|
|
event.currentTarget.checked
|
|
)
|
|
}
|
|
/>
|
|
{isSelected ? (
|
|
<input
|
|
className="outliner-item__rename"
|
|
data-testid="selected-entity-name"
|
|
type="text"
|
|
value={entityNameDraft}
|
|
placeholder={getEntityKindLabel(entity.kind)}
|
|
onChange={(event) =>
|
|
setEntityNameDraft(event.currentTarget.value)
|
|
}
|
|
onBlur={applyEntityNameChange}
|
|
onFocus={(event) => event.currentTarget.select()}
|
|
onKeyDown={(event) =>
|
|
handleInlineNameInputKeyDown(event, () => {
|
|
setEntityNameDraft(
|
|
selectedEntity?.name ?? ""
|
|
);
|
|
})
|
|
}
|
|
/>
|
|
) : (
|
|
<button
|
|
data-testid={`outliner-entity-${entity.id}`}
|
|
className="outliner-item__select"
|
|
type="button"
|
|
onClick={(event) =>
|
|
applySelection(
|
|
applySameKindSelectionClick(
|
|
editorState.selection,
|
|
{
|
|
kind: "entities",
|
|
ids: [entity.id]
|
|
},
|
|
event.shiftKey
|
|
),
|
|
"outliner",
|
|
{
|
|
focusViewport: true
|
|
}
|
|
)
|
|
}
|
|
>
|
|
<span className="outliner-item__title">
|
|
{label}
|
|
</span>
|
|
</button>
|
|
)}
|
|
<button
|
|
className="outliner-item__delete"
|
|
type="button"
|
|
data-testid={`outliner-delete-entity-${entity.id}`}
|
|
aria-label={`Delete ${label}`}
|
|
onClick={() => handleDeleteEntity(entity.id)}
|
|
>
|
|
x
|
|
</button>
|
|
</div>
|
|
{authoredStateSummary === null ? null : (
|
|
<div className="outliner-item__meta">
|
|
{authoredStateSummary}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="outliner-section">
|
|
<div className="label">Imported Assets</div>
|
|
|
|
{projectAssetDisplayList.length === 0 ? (
|
|
<div className="outliner-empty">
|
|
No imported assets are stored in this project.
|
|
</div>
|
|
) : (
|
|
<div
|
|
className="outliner-list"
|
|
data-testid="outliner-project-asset-list"
|
|
>
|
|
{projectAssetDisplayList.map((asset) => (
|
|
<div
|
|
key={asset.id}
|
|
className="outliner-item outliner-item--compact"
|
|
>
|
|
<div className="outliner-item__row">
|
|
<div className="asset-item__content">
|
|
<span className="outliner-item__title">
|
|
{asset.sourceName}
|
|
</span>
|
|
<span className="outliner-item__meta">
|
|
{getProjectAssetKindLabel(asset.kind)}
|
|
</span>
|
|
</div>
|
|
<button
|
|
className="outliner-item__delete"
|
|
type="button"
|
|
data-testid={`outliner-delete-project-asset-${asset.id}`}
|
|
aria-label={`Delete ${asset.sourceName}`}
|
|
onClick={() => handleDeleteProjectAsset(asset)}
|
|
>
|
|
x
|
|
</button>
|
|
</div>
|
|
<div className="outliner-item__meta">
|
|
{formatProjectAssetSummary(asset)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Panel>
|
|
</aside>
|
|
|
|
<div
|
|
ref={editorMainRegionRef}
|
|
className={`editor-main-region ${
|
|
schedulePaneOpen ? "editor-main-region--schedule-open" : ""
|
|
}`.trim()}
|
|
>
|
|
<main
|
|
className={`viewport-region viewport-region--${layoutMode}`}
|
|
data-testid="viewport-shell"
|
|
>
|
|
<div
|
|
ref={viewportPanelsRef}
|
|
className={`viewport-region__panels viewport-region__panels--${layoutMode} ${
|
|
viewportQuadResizeMode === null
|
|
? ""
|
|
: "viewport-region__panels--resizing"
|
|
}`.trim()}
|
|
style={viewportPanelsStyle}
|
|
>
|
|
{VIEWPORT_PANEL_IDS.map((panelId) => (
|
|
<ViewportPanel
|
|
key={panelId}
|
|
panelId={panelId}
|
|
className={`viewport-panel--${panelId}`}
|
|
panelState={editorState.viewportPanels[panelId]}
|
|
layoutMode={layoutMode}
|
|
isActive={activePanelId === panelId}
|
|
world={editorState.document.world}
|
|
sceneDocument={editorState.document}
|
|
editorSimulationController={editorSimulationController}
|
|
projectAssets={editorState.document.assets}
|
|
loadedModelAssets={loadedModelAssets}
|
|
loadedImageAssets={loadedImageAssets}
|
|
whiteboxSelectionMode={whiteboxSelectionMode}
|
|
whiteboxSnapEnabled={whiteboxSnapEnabled}
|
|
whiteboxSnapStepDraft={whiteboxSnapStepDraft}
|
|
whiteboxSnapStep={whiteboxSnapStep}
|
|
viewportGridVisible={viewportGridVisible}
|
|
selection={editorState.selection}
|
|
activeSelectionId={editorState.activeSelectionId}
|
|
terrainBrushState={activeTerrainBrushState}
|
|
toolMode={editorState.toolMode}
|
|
toolPreview={viewportToolPreview}
|
|
transformSession={transformSession}
|
|
canTranslateSelectedTarget={canTranslateSelectedTarget}
|
|
canRotateSelectedTarget={canRotateSelectedTarget}
|
|
canScaleSelectedTarget={canScaleSelectedTarget}
|
|
canSurfaceSnapTransformTarget={canSurfaceSnapTransformTarget}
|
|
cameraState={editorState.viewportPanels[panelId].cameraState}
|
|
focusRequestId={
|
|
focusRequest.panelId === panelId ? focusRequest.id : 0
|
|
}
|
|
focusSelection={focusRequest.selection}
|
|
isAddMenuOpen={addMenuPosition !== null}
|
|
onActivatePanel={handleActivateViewportPanel}
|
|
onOpenAddMenu={handleOpenAddMenuFromButton}
|
|
onSetViewportLayoutMode={handleSetViewportLayoutMode}
|
|
onSetPanelViewMode={handleSetViewportPanelViewMode}
|
|
onSetPanelDisplayMode={handleSetViewportPanelDisplayMode}
|
|
onTerrainBrushCommit={handleCommitTerrainBrushStroke}
|
|
onCommitCreation={handleCommitCreation}
|
|
onCameraStateChange={(cameraState) => {
|
|
store.setViewportPanelCameraState(panelId, cameraState);
|
|
}}
|
|
onToolPreviewChange={(toolPreview) => {
|
|
store.setViewportToolPreview(toolPreview);
|
|
}}
|
|
onBeginTransformOperation={(operation) =>
|
|
beginTransformOperation(operation, "toolbar")
|
|
}
|
|
onToggleTransformSurfaceSnap={toggleTransformSurfaceSnap}
|
|
onWhiteboxSelectionModeChange={
|
|
handleWhiteboxSelectionModeChange
|
|
}
|
|
onViewportGridToggle={handleViewportGridToggle}
|
|
onWhiteboxSnapToggle={handleWhiteboxSnapToggle}
|
|
onWhiteboxSnapStepDraftChange={setWhiteboxSnapStepDraft}
|
|
onWhiteboxSnapStepBlur={handleWhiteboxSnapStepBlur}
|
|
onTransformSessionChange={(nextTransformSession) => {
|
|
latestActiveTransformSessionRef.current =
|
|
nextTransformSession.kind === "active"
|
|
? nextTransformSession
|
|
: null;
|
|
store.setTransformSession(nextTransformSession);
|
|
}}
|
|
onTransformPreviewChange={(nextTransformSession) => {
|
|
latestActiveTransformSessionRef.current =
|
|
nextTransformSession;
|
|
}}
|
|
onTransformCommit={commitTransformSession}
|
|
onTransformCancel={() => cancelTransformSession()}
|
|
onSelectionChange={(selection) =>
|
|
applySelection(selection, "viewport")
|
|
}
|
|
/>
|
|
))}
|
|
{layoutMode !== "quad" ? null : (
|
|
<>
|
|
<div
|
|
className="viewport-region__splitter viewport-region__splitter--vertical"
|
|
data-testid="viewport-quad-splitter-vertical"
|
|
onPointerDown={handleViewportQuadResizeStart("vertical")}
|
|
/>
|
|
<div
|
|
className="viewport-region__splitter viewport-region__splitter--horizontal"
|
|
data-testid="viewport-quad-splitter-horizontal"
|
|
onPointerDown={handleViewportQuadResizeStart("horizontal")}
|
|
/>
|
|
<div
|
|
className="viewport-region__splitter viewport-region__splitter--center"
|
|
data-testid="viewport-quad-splitter-center"
|
|
onPointerDown={handleViewportQuadResizeStart("center")}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
</main>
|
|
|
|
{schedulePaneOpen ? (
|
|
<>
|
|
<div
|
|
className={`schedule-pane-splitter ${
|
|
schedulePaneResizeActive
|
|
? "schedule-pane-splitter--active"
|
|
: ""
|
|
}`.trim()}
|
|
role="separator"
|
|
aria-label="Resize schedule"
|
|
aria-orientation="horizontal"
|
|
tabIndex={0}
|
|
onPointerDown={handleSchedulePaneResizeStart}
|
|
onKeyDown={handleSchedulePaneResizeKeyDown}
|
|
/>
|
|
<div
|
|
className="schedule-pane-shell"
|
|
style={
|
|
{
|
|
height: `${schedulePaneHeight}px`
|
|
} satisfies CSSProperties
|
|
}
|
|
>
|
|
<ProjectSequencerPane
|
|
mode={sequencerMode}
|
|
onSetMode={setSequencerMode}
|
|
targetOptions={projectScheduleTargetOptions}
|
|
teleportTargetOptions={teleportTargetOptions.map(
|
|
({ entity, label }) => ({
|
|
entityId: entity.id,
|
|
label
|
|
})
|
|
)}
|
|
sceneTransitionTargetOptions={sceneTransitionTargetOptions}
|
|
visibilityTargetOptions={sequenceVisibilityTargetOptions}
|
|
scheduler={editorState.projectDocument.scheduler}
|
|
sequences={editorState.projectDocument.sequences}
|
|
npcTalkTargetOptions={npcDialogueSequenceTargetOptions}
|
|
selectedRoutineId={selectedScheduleRoutineId}
|
|
selectedSequenceId={selectedSequenceId}
|
|
onSelectRoutine={setSelectedScheduleRoutineId}
|
|
onSelectSequence={setSelectedSequenceId}
|
|
onAddRoutine={handleCreateScheduleRoutine}
|
|
onAddSequence={handleAddProjectSequence}
|
|
onDeleteRoutine={handleDeleteScheduleRoutine}
|
|
onDeleteSequence={handleDeleteProjectSequence}
|
|
onClose={() => setSchedulePaneOpen(false)}
|
|
onCreateRoutineSequence={
|
|
handleCreateAttachedSequenceForRoutine
|
|
}
|
|
onSetRoutineTarget={(routineId, targetKey) =>
|
|
updateProjectSequencerState(
|
|
"Set project sequencer target",
|
|
"Retargeted sequence placement.",
|
|
(scheduler, sequences) => {
|
|
const routine = scheduler.routines[routineId];
|
|
|
|
if (routine === undefined) {
|
|
throw new Error(
|
|
"Selected sequence placement no longer exists."
|
|
);
|
|
}
|
|
const targetOption =
|
|
resolveProjectScheduleTargetOption(targetKey);
|
|
|
|
if (targetOption === null) {
|
|
throw new Error(
|
|
"Selected sequencer target no longer exists."
|
|
);
|
|
}
|
|
|
|
const previousTargetKey = getControlTargetRefKey(
|
|
routine.target
|
|
);
|
|
|
|
if (targetOption.target.kind === "actor") {
|
|
const attachedSequence =
|
|
routine.sequenceId === null
|
|
? null
|
|
: (sequences.sequences[routine.sequenceId] ??
|
|
null);
|
|
|
|
routine.target = targetOption.target;
|
|
routine.effects = [];
|
|
|
|
if (
|
|
attachedSequence !== null &&
|
|
getProjectSequenceHeldSteps(attachedSequence)
|
|
.length > 0
|
|
) {
|
|
const retargetedSequence =
|
|
cloneSequenceForRetargetedPlacement({
|
|
sequence: attachedSequence,
|
|
targetOption,
|
|
previousTargetKey
|
|
});
|
|
sequences.sequences[retargetedSequence.id] =
|
|
retargetedSequence;
|
|
routine.sequenceId = retargetedSequence.id;
|
|
setSelectedSequenceId(retargetedSequence.id);
|
|
} else {
|
|
const nextSequence =
|
|
createAttachedSequenceForRoutine({
|
|
title: routine.title,
|
|
targetOption,
|
|
routine
|
|
});
|
|
sequences.sequences[nextSequence.id] = nextSequence;
|
|
routine.sequenceId = nextSequence.id;
|
|
setSelectedSequenceId(nextSequence.id);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (targetOption.target.kind === "global") {
|
|
const attachedSequence =
|
|
routine.sequenceId === null
|
|
? null
|
|
: (sequences.sequences[routine.sequenceId] ??
|
|
null);
|
|
|
|
routine.target = targetOption.target;
|
|
routine.effects = [];
|
|
|
|
const nextSequence = createProjectSequence({
|
|
title: attachedSequence?.title ?? routine.title,
|
|
effects:
|
|
attachedSequence === null
|
|
? []
|
|
: attachedSequence.effects
|
|
.filter(
|
|
(effect) => effect.stepClass === "impulse"
|
|
)
|
|
.map(cloneSequenceEffect)
|
|
});
|
|
sequences.sequences[nextSequence.id] = nextSequence;
|
|
routine.sequenceId = nextSequence.id;
|
|
setSelectedSequenceId(nextSequence.id);
|
|
return;
|
|
}
|
|
|
|
const effectOptions =
|
|
listProjectScheduleEffectOptions(targetOption);
|
|
const currentPrimaryEffect = routine.effects[0] ?? null;
|
|
const nextEffectOptionId =
|
|
currentPrimaryEffect === null
|
|
? effectOptions[0]?.id
|
|
: (effectOptions.find(
|
|
(effectOption) =>
|
|
effectOption.id ===
|
|
getProjectScheduleEffectOptionId(
|
|
currentPrimaryEffect
|
|
)
|
|
)?.id ?? effectOptions[0]?.id);
|
|
|
|
if (nextEffectOptionId === undefined) {
|
|
throw new Error(
|
|
"Selected sequencer target does not expose a schedulable effect."
|
|
);
|
|
}
|
|
|
|
const attachedSequence =
|
|
routine.sequenceId === null
|
|
? null
|
|
: (sequences.sequences[routine.sequenceId] ?? null);
|
|
|
|
routine.target = targetOption.target;
|
|
routine.effects = [];
|
|
|
|
if (
|
|
attachedSequence !== null &&
|
|
getProjectSequenceHeldSteps(attachedSequence).length >
|
|
0
|
|
) {
|
|
const retargetedSequence =
|
|
cloneSequenceForRetargetedPlacement({
|
|
sequence: attachedSequence,
|
|
targetOption,
|
|
previousTargetKey
|
|
});
|
|
sequences.sequences[retargetedSequence.id] =
|
|
retargetedSequence;
|
|
routine.sequenceId = retargetedSequence.id;
|
|
setSelectedSequenceId(retargetedSequence.id);
|
|
return;
|
|
}
|
|
|
|
const nextSequence = createProjectSequence({
|
|
title: attachedSequence?.title ?? routine.title,
|
|
effects: [
|
|
{
|
|
stepClass: "held",
|
|
type: "controlEffect",
|
|
effect: createProjectScheduleEffectFromOption({
|
|
targetOption,
|
|
effectOptionId: nextEffectOptionId,
|
|
previousEffect: currentPrimaryEffect
|
|
})
|
|
}
|
|
]
|
|
});
|
|
sequences.sequences[nextSequence.id] = nextSequence;
|
|
routine.sequenceId = nextSequence.id;
|
|
setSelectedSequenceId(nextSequence.id);
|
|
}
|
|
)
|
|
}
|
|
onSetRoutineTitle={(routineId, title) =>
|
|
updateProjectScheduleRoutine(
|
|
routineId,
|
|
"Rename sequence placement",
|
|
"Updated sequence placement title.",
|
|
(routine) => {
|
|
routine.title = title.trim();
|
|
}
|
|
)
|
|
}
|
|
onSetRoutineEnabled={(routineId, enabled) =>
|
|
updateProjectScheduleRoutine(
|
|
routineId,
|
|
"Toggle sequence placement",
|
|
enabled
|
|
? "Enabled sequence placement."
|
|
: "Disabled sequence placement.",
|
|
(routine) => {
|
|
routine.enabled = enabled;
|
|
}
|
|
)
|
|
}
|
|
onSetRoutineStartHour={(routineId, startHour) =>
|
|
updateProjectScheduleRoutine(
|
|
routineId,
|
|
"Set project sequencer start time",
|
|
"Updated sequence placement start time.",
|
|
(routine) => {
|
|
routine.startHour = normalizeTimeOfDayHours(startHour);
|
|
if (routine.startHour === routine.endHour) {
|
|
throw new Error(
|
|
"Sequencer clip start and end hours must differ."
|
|
);
|
|
}
|
|
}
|
|
)
|
|
}
|
|
onSetRoutineEndHour={(routineId, endHour) =>
|
|
updateProjectScheduleRoutine(
|
|
routineId,
|
|
"Set project sequencer end time",
|
|
"Updated sequence placement end time.",
|
|
(routine) => {
|
|
routine.endHour = normalizeTimeOfDayHours(endHour);
|
|
if (routine.startHour === routine.endHour) {
|
|
throw new Error(
|
|
"Sequencer clip start and end hours must differ."
|
|
);
|
|
}
|
|
}
|
|
)
|
|
}
|
|
onSetRoutinePriority={(routineId, priority) =>
|
|
updateProjectScheduleRoutine(
|
|
routineId,
|
|
"Set project sequencer priority",
|
|
"Updated sequence placement priority.",
|
|
(routine) => {
|
|
if (!Number.isFinite(priority)) {
|
|
throw new Error(
|
|
"Sequencer clip priority must be a finite number."
|
|
);
|
|
}
|
|
|
|
routine.priority = Math.trunc(priority);
|
|
}
|
|
)
|
|
}
|
|
onSetRoutineSequenceId={(routineId, sequenceId) =>
|
|
updateProjectScheduleRoutine(
|
|
routineId,
|
|
"Set project sequencer sequence",
|
|
"Timeline clip now resolves a project sequence.",
|
|
(routine) => {
|
|
routine.sequenceId = sequenceId;
|
|
}
|
|
)
|
|
}
|
|
onSetSequenceTitle={(sequenceId, title) =>
|
|
updateProjectSequence(
|
|
sequenceId,
|
|
"Rename project sequence",
|
|
"Updated project sequence title.",
|
|
(sequence) => {
|
|
sequence.title = title.trim() || "Sequence";
|
|
}
|
|
)
|
|
}
|
|
onAddControlEffect={handleAddProjectSequenceControlEffect}
|
|
onAddNpcTalkEffect={handleAddProjectSequenceNpcTalkStep}
|
|
onAddTeleportStep={handleAddProjectSequenceTeleportStep}
|
|
onAddSceneTransitionStep={
|
|
handleAddProjectSequenceSceneTransitionStep
|
|
}
|
|
onAddVisibilityStep={handleAddProjectSequenceVisibilityStep}
|
|
onDeleteStep={handleDeleteProjectSequenceStep}
|
|
onSetControlStepTarget={
|
|
updateProjectSequenceControlStepTarget
|
|
}
|
|
onSetControlStepEffectOption={
|
|
updateProjectSequenceControlStepEffectOption
|
|
}
|
|
onSetControlStepNumericValue={
|
|
updateProjectSequenceControlStepNumericValue
|
|
}
|
|
onSetControlStepColorValue={
|
|
updateProjectSequenceControlStepColorValue
|
|
}
|
|
onSetControlStepAnimationClip={
|
|
updateProjectSequenceControlStepAnimationClip
|
|
}
|
|
onSetControlStepAnimationLoop={
|
|
updateProjectSequenceControlStepAnimationLoop
|
|
}
|
|
onSetControlStepPathId={
|
|
updateProjectSequenceControlStepPathId
|
|
}
|
|
onSetControlStepPathSpeed={
|
|
updateProjectSequenceControlStepPathSpeed
|
|
}
|
|
onSetControlStepPathLoop={
|
|
updateProjectSequenceControlStepPathLoop
|
|
}
|
|
onSetControlStepPathSmooth={
|
|
updateProjectSequenceControlStepPathSmooth
|
|
}
|
|
onSetNpcTalkStepNpcEntityId={
|
|
updateProjectSequenceNpcTalkStepNpcEntityId
|
|
}
|
|
onSetNpcTalkStepDialogueId={
|
|
updateProjectSequenceNpcTalkStepDialogueId
|
|
}
|
|
onSetTeleportStepTarget={
|
|
updateProjectSequenceTeleportStepTarget
|
|
}
|
|
onSetSceneTransitionStepTarget={
|
|
updateProjectSequenceSceneTransitionStepTarget
|
|
}
|
|
onSetVisibilityStepTarget={
|
|
updateProjectSequenceVisibilityStepTarget
|
|
}
|
|
onSetVisibilityStepMode={
|
|
updateProjectSequenceVisibilityStepMode
|
|
}
|
|
/>
|
|
</div>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
|
|
<aside className="side-column">
|
|
{editorState.selection.kind === "none" ? (
|
|
<>
|
|
<Panel title="Scene">
|
|
<div className="stat-card">
|
|
<div className="label">Active Scene</div>
|
|
<div className="value">{activeProjectScene.name}</div>
|
|
<div className="material-summary">
|
|
Runner overlay data is authored per scene and shown during
|
|
initial runtime load and future scene transitions.
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Runner Loading Overlay</div>
|
|
<label className="form-field">
|
|
<span className="label">Fade Color</span>
|
|
<input
|
|
data-testid="scene-loading-color"
|
|
className="color-input"
|
|
type="color"
|
|
value={activeProjectScene.loadingScreen.colorHex}
|
|
onChange={(event) =>
|
|
applySceneLoadingColor(event.currentTarget.value)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Headline</span>
|
|
<input
|
|
data-testid="scene-loading-headline"
|
|
className="text-input"
|
|
type="text"
|
|
placeholder="Optional hint or location title"
|
|
value={sceneLoadingHeadlineDraft}
|
|
onChange={(event) =>
|
|
setSceneLoadingHeadlineDraft(event.currentTarget.value)
|
|
}
|
|
onBlur={applySceneLoadingHeadline}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter") {
|
|
applySceneLoadingHeadline();
|
|
}
|
|
}}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Description</span>
|
|
<input
|
|
data-testid="scene-loading-description"
|
|
className="text-input"
|
|
type="text"
|
|
placeholder="Optional loading note or gameplay tip"
|
|
value={sceneLoadingDescriptionDraft}
|
|
onChange={(event) =>
|
|
setSceneLoadingDescriptionDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={applySceneLoadingDescription}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter") {
|
|
applySceneLoadingDescription();
|
|
}
|
|
}}
|
|
/>
|
|
</label>
|
|
<div className="material-summary">
|
|
Scene name is always shown automatically. Headline and
|
|
description are optional.
|
|
</div>
|
|
</div>
|
|
</Panel>
|
|
|
|
<Panel title="Project Time">
|
|
<div className="stat-card">
|
|
<div className="label">Clock</div>
|
|
<div className="value">
|
|
Day {editorSimulationClock.dayCount + 1} ·{" "}
|
|
{formatTimeOfDayHours(editorSimulationClock.timeOfDayHours)}{" "}
|
|
·{" "}
|
|
{formatRuntimeDayPhaseLabel(
|
|
editorSimulationTimeState.dayPhase
|
|
)}
|
|
</div>
|
|
<div className="material-summary">
|
|
{!editorSimulationOverrideActive
|
|
? "The editor viewport follows the authored project start day and time."
|
|
: editorSimulationPlaying
|
|
? "The editor viewport is running on an overridden clock."
|
|
: "The editor viewport is paused on an overridden clock."}
|
|
</div>
|
|
{!editorSimulationOverrideActive ? null : (
|
|
<div className="material-summary">
|
|
Authored start: Day{" "}
|
|
{editorState.projectDocument.time.startDayNumber} ·{" "}
|
|
{formatTimeOfDayHours(
|
|
editorState.projectDocument.time.startTimeOfDayHours
|
|
)}
|
|
</div>
|
|
)}
|
|
<div className="inline-actions">
|
|
<button
|
|
className="toolbar__button toolbar__button--compact"
|
|
type="button"
|
|
onClick={() => handleStepEditorSimulation(-0.25)}
|
|
>
|
|
-15m
|
|
</button>
|
|
<button
|
|
className="toolbar__button toolbar__button--compact"
|
|
type="button"
|
|
onClick={() => handleStepEditorSimulation(0.25)}
|
|
>
|
|
+15m
|
|
</button>
|
|
<button
|
|
className="toolbar__button toolbar__button--compact"
|
|
type="button"
|
|
onClick={
|
|
editorSimulationPlaying
|
|
? handlePauseEditorSimulation
|
|
: handlePlayEditorSimulation
|
|
}
|
|
>
|
|
{editorSimulationPlaying ? "Pause" : "Play"}
|
|
</button>
|
|
<button
|
|
className="toolbar__button toolbar__button--compact"
|
|
type="button"
|
|
onClick={handleResetEditorSimulation}
|
|
>
|
|
Reset
|
|
</button>
|
|
</div>
|
|
{editorSimulationMessage === null ? null : (
|
|
<div className="material-summary">
|
|
{editorSimulationMessage}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Notebook</div>
|
|
<button
|
|
className="toolbar__button toolbar__button--compact"
|
|
type="button"
|
|
onClick={() => {
|
|
setSchedulePaneOpen(true);
|
|
setSequencerMode("timeline");
|
|
}}
|
|
>
|
|
Open Sequencer
|
|
</button>
|
|
<div className="material-summary">
|
|
Time-based actor behavior now resolves through the shared
|
|
sequencer/control surface path.
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Cycle Settings</div>
|
|
<div className="vector-inputs vector-inputs--two">
|
|
<label className="form-field">
|
|
<span className="label">Start Day</span>
|
|
<input
|
|
className="text-input"
|
|
type="number"
|
|
min="1"
|
|
step="1"
|
|
value={projectTimeStartDayNumberDraft}
|
|
onChange={(event) =>
|
|
setProjectTimeStartDayNumberDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={applyProjectTimeStartDayNumber}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyProjectTimeStartDayNumber
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyProjectTimeStartDayNumber
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyProjectTimeStartDayNumber
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Start Time</span>
|
|
<input
|
|
data-testid="project-time-start-hours"
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
max="24"
|
|
step="0.25"
|
|
value={projectTimeStartTimeOfDayDraft}
|
|
onChange={(event) =>
|
|
setProjectTimeStartTimeOfDayDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={applyProjectTimeStartTimeOfDay}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyProjectTimeStartTimeOfDay
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyProjectTimeStartTimeOfDay
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyProjectTimeStartTimeOfDay
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
<div className="vector-inputs vector-inputs--two">
|
|
<label className="form-field">
|
|
<span className="label">Minutes Per Day</span>
|
|
<input
|
|
data-testid="project-time-day-length-minutes"
|
|
className="text-input"
|
|
type="number"
|
|
min="1"
|
|
step="1"
|
|
value={projectTimeDayLengthMinutesDraft}
|
|
onChange={(event) =>
|
|
setProjectTimeDayLengthMinutesDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={applyProjectTimeDayLengthMinutes}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyProjectTimeDayLengthMinutes
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyProjectTimeDayLengthMinutes
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyProjectTimeDayLengthMinutes
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
<div className="material-summary">
|
|
Start Day and Start Time now immediately reseed the editor
|
|
viewport. Default is 24 real minutes for one full 24-hour
|
|
cycle.
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Sun Windows</div>
|
|
<div className="vector-inputs vector-inputs--two">
|
|
<label className="form-field">
|
|
<span className="label">Sunrise</span>
|
|
<input
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
max="24"
|
|
step="0.25"
|
|
value={projectTimeSunriseTimeOfDayDraft}
|
|
onChange={(event) =>
|
|
setProjectTimeSunriseTimeOfDayDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={applyProjectTimeSunriseTimeOfDay}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyProjectTimeSunriseTimeOfDay
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyProjectTimeSunriseTimeOfDay
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyProjectTimeSunriseTimeOfDay
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Sunset</span>
|
|
<input
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
max="24"
|
|
step="0.25"
|
|
value={projectTimeSunsetTimeOfDayDraft}
|
|
onChange={(event) =>
|
|
setProjectTimeSunsetTimeOfDayDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={applyProjectTimeSunsetTimeOfDay}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyProjectTimeSunsetTimeOfDay
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyProjectTimeSunsetTimeOfDay
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyProjectTimeSunsetTimeOfDay
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
<div className="vector-inputs vector-inputs--two">
|
|
<label className="form-field">
|
|
<span className="label">Dawn Hours</span>
|
|
<input
|
|
className="text-input"
|
|
type="number"
|
|
min="0.1"
|
|
step="0.1"
|
|
value={projectTimeDawnDurationHoursDraft}
|
|
onChange={(event) =>
|
|
setProjectTimeDawnDurationHoursDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={applyProjectTimeDawnDurationHours}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyProjectTimeDawnDurationHours
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyProjectTimeDawnDurationHours
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyProjectTimeDawnDurationHours
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Dusk Hours</span>
|
|
<input
|
|
className="text-input"
|
|
type="number"
|
|
min="0.1"
|
|
step="0.1"
|
|
value={projectTimeDuskDurationHoursDraft}
|
|
onChange={(event) =>
|
|
setProjectTimeDuskDurationHoursDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={applyProjectTimeDuskDurationHours}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyProjectTimeDuskDurationHours
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyProjectTimeDuskDurationHours
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyProjectTimeDuskDurationHours
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
<div className="material-summary">
|
|
Sunrise must stay earlier than sunset. Dawn and dusk
|
|
durations define how broadly the twilight transition blends
|
|
around each boundary.
|
|
</div>
|
|
</div>
|
|
|
|
<div className="material-summary">
|
|
Environment authoring lives in World. Project Time now only
|
|
defines the shared clock and daylight window.
|
|
</div>
|
|
</Panel>
|
|
|
|
<Panel title="World">
|
|
<div className="stat-card">
|
|
<div className="label">Day Environment</div>
|
|
<div
|
|
className="value"
|
|
data-testid="world-background-mode-value"
|
|
>
|
|
{formatWorldBackgroundLabel(
|
|
editorState.document.world.background
|
|
)}
|
|
</div>
|
|
<div className="material-summary">
|
|
{editorState.document.world.projectTimeLightingEnabled
|
|
? "This scene uses its authored day environment plus dawn, dusk, and night overrides from this panel."
|
|
: "This scene keeps its authored day environment at all times."}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<label className="form-field form-field--toggle">
|
|
<span className="label">Follow Project Time</span>
|
|
<input
|
|
type="checkbox"
|
|
checked={
|
|
editorState.document.world.projectTimeLightingEnabled
|
|
}
|
|
onChange={(event) =>
|
|
applySceneProjectTimeLightingEnabled(
|
|
event.currentTarget.checked
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<div className="material-summary">
|
|
Disable this when a scene should keep its authored world sky
|
|
and lighting instead of the global sunrise, sunset, dawn,
|
|
dusk, and night overrides.
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<label className="form-field form-field--toggle">
|
|
<span className="label">Show Sun & Moon</span>
|
|
<input
|
|
data-testid="world-show-celestial-bodies"
|
|
type="checkbox"
|
|
checked={editorState.document.world.showCelestialBodies}
|
|
onChange={(event) =>
|
|
applySceneShowCelestialBodies(
|
|
event.currentTarget.checked
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<div className="material-summary">
|
|
Draws sun and moon visuals in both the editor viewport and
|
|
the runner. In shader mode, the sky shader owns those discs
|
|
directly.
|
|
</div>
|
|
</div>
|
|
|
|
<Panel title="Day Environment">
|
|
<div className="stat-card">
|
|
<div
|
|
className="world-background-preview"
|
|
data-testid="world-background-preview"
|
|
style={createWorldBackgroundStyle(
|
|
editorState.document.world.background,
|
|
editorState.document.world.background.mode === "image"
|
|
? (loadedImageAssets[
|
|
editorState.document.world.background.assetId
|
|
]?.previewUrl ?? null)
|
|
: null,
|
|
editorState.document.world.background.mode === "shader"
|
|
? {
|
|
topColorHex:
|
|
editorState.document.world.shaderSky
|
|
.dayTopColorHex,
|
|
bottomColorHex:
|
|
editorState.document.world.shaderSky
|
|
.dayBottomColorHex
|
|
}
|
|
: null
|
|
)}
|
|
/>
|
|
<div className="material-summary">
|
|
{describeWorldBackground(
|
|
editorState.document.world.background,
|
|
editorState.document.assets
|
|
)}
|
|
</div>
|
|
{editorState.document.world.background.mode !==
|
|
"image" ? null : (
|
|
<div
|
|
className="material-summary"
|
|
data-testid="world-background-asset-value"
|
|
>
|
|
Background Asset:{" "}
|
|
{editorState.document.assets[
|
|
editorState.document.world.background.assetId
|
|
]?.sourceName ??
|
|
editorState.document.world.background.assetId}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Background Mode</div>
|
|
<div className="inline-actions">
|
|
<button
|
|
className={`toolbar__button ${editorState.document.world.background.mode === "solid" ? "toolbar__button--active" : ""}`}
|
|
type="button"
|
|
data-testid="world-background-mode-solid"
|
|
onClick={() => applyWorldBackgroundMode("solid")}
|
|
>
|
|
Solid
|
|
</button>
|
|
<button
|
|
className={`toolbar__button ${
|
|
editorState.document.world.background.mode ===
|
|
"verticalGradient"
|
|
? "toolbar__button--active"
|
|
: ""
|
|
}`}
|
|
type="button"
|
|
data-testid="world-background-mode-gradient"
|
|
onClick={() =>
|
|
applyWorldBackgroundMode("verticalGradient")
|
|
}
|
|
>
|
|
Gradient
|
|
</button>
|
|
<button
|
|
className={`toolbar__button ${editorState.document.world.background.mode === "image" ? "toolbar__button--active" : ""}`}
|
|
type="button"
|
|
data-testid="world-background-mode-image"
|
|
onClick={() => applyWorldBackgroundMode("image")}
|
|
>
|
|
Image
|
|
</button>
|
|
<button
|
|
className={`toolbar__button ${editorState.document.world.background.mode === "shader" ? "toolbar__button--active" : ""}`}
|
|
type="button"
|
|
data-testid="world-background-mode-shader"
|
|
onClick={() => applyWorldBackgroundMode("shader")}
|
|
>
|
|
Shader
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{editorState.document.world.background.mode === "image" && (
|
|
<div className="form-section">
|
|
<div className="label">Image Background</div>
|
|
<label className="form-field">
|
|
<span className="label">Image</span>
|
|
<select
|
|
data-testid="world-background-asset-select"
|
|
className="text-input"
|
|
value={editorState.document.world.background.assetId}
|
|
onChange={(event) =>
|
|
applyWorldBackgroundMode(
|
|
"image",
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
>
|
|
{imageAssetList.map((asset) => (
|
|
<option key={asset.id} value={asset.id}>
|
|
{asset.sourceName}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Intensity</span>
|
|
<input
|
|
data-testid="world-background-environment-intensity"
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
step="0.1"
|
|
value={backgroundEnvironmentIntensityDraft}
|
|
onChange={(event) =>
|
|
setBackgroundEnvironmentIntensityDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={applyBackgroundEnvironmentIntensity}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyBackgroundEnvironmentIntensity
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyBackgroundEnvironmentIntensity
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyBackgroundEnvironmentIntensity
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
)}
|
|
|
|
{(editorState.document.world.background.mode === "solid" ||
|
|
editorState.document.world.background.mode ===
|
|
"verticalGradient") && (
|
|
<div className="form-section">
|
|
<div className="label">Background Colors</div>
|
|
{editorState.document.world.background.mode ===
|
|
"solid" ? (
|
|
<label className="form-field">
|
|
<span className="label">Color</span>
|
|
<input
|
|
data-testid="world-background-solid-color"
|
|
className="color-input"
|
|
type="color"
|
|
value={
|
|
editorState.document.world.background.colorHex
|
|
}
|
|
onChange={(event) =>
|
|
applyWorldBackgroundColor(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
) : (
|
|
<div className="vector-inputs vector-inputs--two">
|
|
<label className="form-field">
|
|
<span className="label">Top</span>
|
|
<input
|
|
data-testid="world-background-top-color"
|
|
className="color-input"
|
|
type="color"
|
|
value={
|
|
editorState.document.world.background
|
|
.topColorHex
|
|
}
|
|
onChange={(event) =>
|
|
applyWorldGradientColor(
|
|
"top",
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Bottom</span>
|
|
<input
|
|
data-testid="world-background-bottom-color"
|
|
className="color-input"
|
|
type="color"
|
|
value={
|
|
editorState.document.world.background
|
|
.bottomColorHex
|
|
}
|
|
onChange={(event) =>
|
|
applyWorldGradientColor(
|
|
"bottom",
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{editorState.document.world.background.mode === "shader" && (
|
|
<>
|
|
<div className="form-section">
|
|
<div className="label">Shader Sky</div>
|
|
<div className="material-summary">
|
|
Built-in preset: Default Sky. The shader blends these
|
|
day colors with the authored dawn, dusk, and night
|
|
phase colors from this panel.
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Day Color Basis</div>
|
|
<div className="vector-inputs vector-inputs--two">
|
|
<label className="form-field">
|
|
<span className="label">Top</span>
|
|
<input
|
|
className="color-input"
|
|
type="color"
|
|
value={
|
|
editorState.document.world.shaderSky
|
|
.dayTopColorHex
|
|
}
|
|
onChange={(event) =>
|
|
applyShaderSkyDayColor(
|
|
"top",
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Bottom</span>
|
|
<input
|
|
className="color-input"
|
|
type="color"
|
|
value={
|
|
editorState.document.world.shaderSky
|
|
.dayBottomColorHex
|
|
}
|
|
onChange={(event) =>
|
|
applyShaderSkyDayColor(
|
|
"bottom",
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Sky Shape</div>
|
|
<div className="vector-inputs vector-inputs--two">
|
|
<label className="form-field">
|
|
<span className="label">Horizon Height</span>
|
|
<input
|
|
className="text-input"
|
|
type="number"
|
|
min="-0.5"
|
|
max="0.5"
|
|
step="0.01"
|
|
value={
|
|
editorState.document.world.shaderSky
|
|
.horizonHeight
|
|
}
|
|
onChange={(event) =>
|
|
applyShaderSkyNumericSetting(
|
|
"Set shader sky horizon height",
|
|
"Updated the shader sky horizon height.",
|
|
event.currentTarget.valueAsNumber,
|
|
(shaderSky, nextValue) => {
|
|
shaderSky.horizonHeight = nextValue;
|
|
}
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Celestial Discs</div>
|
|
<div className="vector-inputs vector-inputs--two">
|
|
<label className="form-field">
|
|
<span className="label">Sun Size</span>
|
|
<input
|
|
className="text-input"
|
|
type="number"
|
|
min="0.1"
|
|
step="0.1"
|
|
value={
|
|
editorState.document.world.shaderSky.celestial
|
|
.sunDiscSizeDegrees
|
|
}
|
|
onChange={(event) =>
|
|
applyShaderSkyNumericSetting(
|
|
"Set shader sky sun disc size",
|
|
"Updated the shader sky sun disc size.",
|
|
event.currentTarget.valueAsNumber,
|
|
(shaderSky, nextValue) => {
|
|
shaderSky.celestial.sunDiscSizeDegrees =
|
|
nextValue;
|
|
}
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Moon Size</span>
|
|
<input
|
|
className="text-input"
|
|
type="number"
|
|
min="0.1"
|
|
step="0.1"
|
|
value={
|
|
editorState.document.world.shaderSky.celestial
|
|
.moonDiscSizeDegrees
|
|
}
|
|
onChange={(event) =>
|
|
applyShaderSkyNumericSetting(
|
|
"Set shader sky moon disc size",
|
|
"Updated the shader sky moon disc size.",
|
|
event.currentTarget.valueAsNumber,
|
|
(shaderSky, nextValue) => {
|
|
shaderSky.celestial.moonDiscSizeDegrees =
|
|
nextValue;
|
|
}
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Stars</div>
|
|
<div className="vector-inputs vector-inputs--two">
|
|
<label className="form-field">
|
|
<span className="label">Density</span>
|
|
<input
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
step="0.05"
|
|
value={
|
|
editorState.document.world.shaderSky.stars
|
|
.density
|
|
}
|
|
onChange={(event) =>
|
|
applyShaderSkyNumericSetting(
|
|
"Set shader sky star density",
|
|
"Updated the shader sky star density.",
|
|
event.currentTarget.valueAsNumber,
|
|
(shaderSky, nextValue) => {
|
|
shaderSky.stars.density = nextValue;
|
|
}
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Brightness</span>
|
|
<input
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
step="0.05"
|
|
value={
|
|
editorState.document.world.shaderSky.stars
|
|
.brightness
|
|
}
|
|
onChange={(event) =>
|
|
applyShaderSkyNumericSetting(
|
|
"Set shader sky star brightness",
|
|
"Updated the shader sky star brightness.",
|
|
event.currentTarget.valueAsNumber,
|
|
(shaderSky, nextValue) => {
|
|
shaderSky.stars.brightness = nextValue;
|
|
}
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Horizon Offset</span>
|
|
<input
|
|
className="text-input"
|
|
type="number"
|
|
min="-0.5"
|
|
max="0.5"
|
|
step="0.01"
|
|
value={
|
|
editorState.document.world.shaderSky.stars
|
|
.horizonFadeOffset
|
|
}
|
|
onChange={(event) =>
|
|
applyShaderSkyNumericSetting(
|
|
"Set shader sky star horizon offset",
|
|
"Updated the shader sky star horizon offset.",
|
|
event.currentTarget.valueAsNumber,
|
|
(shaderSky, nextValue) => {
|
|
shaderSky.stars.horizonFadeOffset =
|
|
nextValue;
|
|
}
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<label className="form-field form-field--toggle">
|
|
<span className="label">Aurora Borealis</span>
|
|
<input
|
|
type="checkbox"
|
|
checked={
|
|
editorState.document.world.shaderSky.aurora
|
|
.enabled
|
|
}
|
|
onChange={(event) =>
|
|
applyShaderSkyAuroraEnabled(
|
|
event.currentTarget.checked
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<div className="material-summary">
|
|
Adds giant animated northern lights to the shader sky.
|
|
Aurora is weighted toward night and late twilight and
|
|
fades out in daylight.
|
|
</div>
|
|
</div>
|
|
|
|
{editorState.document.world.shaderSky.aurora.enabled && (
|
|
<div className="form-section">
|
|
<div className="label">Aurora Settings</div>
|
|
<div className="vector-inputs vector-inputs--two">
|
|
<label className="form-field">
|
|
<span className="label">Intensity</span>
|
|
<input
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
step="0.05"
|
|
value={
|
|
editorState.document.world.shaderSky.aurora
|
|
.intensity
|
|
}
|
|
onChange={(event) =>
|
|
applyShaderSkyNumericSetting(
|
|
"Set shader sky aurora intensity",
|
|
"Updated the shader sky aurora intensity.",
|
|
event.currentTarget.valueAsNumber,
|
|
(shaderSky, nextValue) => {
|
|
shaderSky.aurora.intensity = nextValue;
|
|
}
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Height</span>
|
|
<input
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
max="1"
|
|
step="0.05"
|
|
value={
|
|
editorState.document.world.shaderSky.aurora
|
|
.height
|
|
}
|
|
onChange={(event) =>
|
|
applyShaderSkyNumericSetting(
|
|
"Set shader sky aurora height",
|
|
"Updated the shader sky aurora height.",
|
|
event.currentTarget.valueAsNumber,
|
|
(shaderSky, nextValue) => {
|
|
shaderSky.aurora.height = nextValue;
|
|
}
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Thickness</span>
|
|
<input
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
max="1"
|
|
step="0.05"
|
|
value={
|
|
editorState.document.world.shaderSky.aurora
|
|
.thickness
|
|
}
|
|
onChange={(event) =>
|
|
applyShaderSkyNumericSetting(
|
|
"Set shader sky aurora thickness",
|
|
"Updated the shader sky aurora thickness.",
|
|
event.currentTarget.valueAsNumber,
|
|
(shaderSky, nextValue) => {
|
|
shaderSky.aurora.thickness = nextValue;
|
|
}
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Speed</span>
|
|
<input
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
step="0.01"
|
|
value={
|
|
editorState.document.world.shaderSky.aurora
|
|
.speed
|
|
}
|
|
onChange={(event) =>
|
|
applyShaderSkyNumericSetting(
|
|
"Set shader sky aurora speed",
|
|
"Updated the shader sky aurora speed.",
|
|
event.currentTarget.valueAsNumber,
|
|
(shaderSky, nextValue) => {
|
|
shaderSky.aurora.speed = nextValue;
|
|
}
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Primary Color</span>
|
|
<input
|
|
className="color-input"
|
|
type="color"
|
|
value={
|
|
editorState.document.world.shaderSky.aurora
|
|
.primaryColorHex
|
|
}
|
|
onChange={(event) =>
|
|
applyShaderSkySettings(
|
|
"Set shader sky aurora primary color",
|
|
"Updated the shader sky aurora primary color.",
|
|
(shaderSky) => {
|
|
shaderSky.aurora.primaryColorHex =
|
|
event.currentTarget.value;
|
|
}
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Secondary Color</span>
|
|
<input
|
|
className="color-input"
|
|
type="color"
|
|
value={
|
|
editorState.document.world.shaderSky.aurora
|
|
.secondaryColorHex
|
|
}
|
|
onChange={(event) =>
|
|
applyShaderSkySettings(
|
|
"Set shader sky aurora secondary color",
|
|
"Updated the shader sky aurora secondary color.",
|
|
(shaderSky) => {
|
|
shaderSky.aurora.secondaryColorHex =
|
|
event.currentTarget.value;
|
|
}
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="form-section">
|
|
<div className="label">Clouds</div>
|
|
<div className="vector-inputs vector-inputs--two">
|
|
<label className="form-field">
|
|
<span className="label">Coverage</span>
|
|
<input
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
max="1"
|
|
step="0.05"
|
|
value={
|
|
editorState.document.world.shaderSky.clouds
|
|
.coverage
|
|
}
|
|
onChange={(event) =>
|
|
applyShaderSkyNumericSetting(
|
|
"Set shader sky cloud coverage",
|
|
"Updated the shader sky cloud coverage.",
|
|
event.currentTarget.valueAsNumber,
|
|
(shaderSky, nextValue) => {
|
|
shaderSky.clouds.coverage = nextValue;
|
|
}
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Density</span>
|
|
<input
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
step="0.05"
|
|
value={
|
|
editorState.document.world.shaderSky.clouds
|
|
.density
|
|
}
|
|
onChange={(event) =>
|
|
applyShaderSkyNumericSetting(
|
|
"Set shader sky cloud density",
|
|
"Updated the shader sky cloud density.",
|
|
event.currentTarget.valueAsNumber,
|
|
(shaderSky, nextValue) => {
|
|
shaderSky.clouds.density = nextValue;
|
|
}
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Softness</span>
|
|
<input
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
max="1"
|
|
step="0.05"
|
|
value={
|
|
editorState.document.world.shaderSky.clouds
|
|
.softness
|
|
}
|
|
onChange={(event) =>
|
|
applyShaderSkyNumericSetting(
|
|
"Set shader sky cloud softness",
|
|
"Updated the shader sky cloud softness.",
|
|
event.currentTarget.valueAsNumber,
|
|
(shaderSky, nextValue) => {
|
|
shaderSky.clouds.softness = nextValue;
|
|
}
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Scale</span>
|
|
<input
|
|
className="text-input"
|
|
type="number"
|
|
min="0.1"
|
|
step="0.05"
|
|
value={
|
|
editorState.document.world.shaderSky.clouds
|
|
.scale
|
|
}
|
|
onChange={(event) =>
|
|
applyShaderSkyNumericSetting(
|
|
"Set shader sky cloud scale",
|
|
"Updated the shader sky cloud scale.",
|
|
event.currentTarget.valueAsNumber,
|
|
(shaderSky, nextValue) => {
|
|
shaderSky.clouds.scale = nextValue;
|
|
}
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Height</span>
|
|
<input
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
max="1"
|
|
step="0.05"
|
|
value={
|
|
editorState.document.world.shaderSky.clouds
|
|
.height
|
|
}
|
|
onChange={(event) =>
|
|
applyShaderSkyNumericSetting(
|
|
"Set shader sky cloud height",
|
|
"Updated the shader sky cloud height.",
|
|
event.currentTarget.valueAsNumber,
|
|
(shaderSky, nextValue) => {
|
|
shaderSky.clouds.height = nextValue;
|
|
}
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Height Variation</span>
|
|
<input
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
max="1"
|
|
step="0.05"
|
|
value={
|
|
editorState.document.world.shaderSky.clouds
|
|
.heightVariation
|
|
}
|
|
onChange={(event) =>
|
|
applyShaderSkyNumericSetting(
|
|
"Set shader sky cloud height variation",
|
|
"Updated the shader sky cloud height variation.",
|
|
event.currentTarget.valueAsNumber,
|
|
(shaderSky, nextValue) => {
|
|
shaderSky.clouds.heightVariation =
|
|
nextValue;
|
|
}
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Tint</span>
|
|
<input
|
|
className="color-input"
|
|
type="color"
|
|
value={
|
|
editorState.document.world.shaderSky.clouds
|
|
.tintHex
|
|
}
|
|
onChange={(event) =>
|
|
applyShaderSkySettings(
|
|
"Set shader sky cloud tint",
|
|
"Updated the shader sky cloud tint.",
|
|
(shaderSky) => {
|
|
shaderSky.clouds.tintHex =
|
|
event.currentTarget.value;
|
|
}
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Opacity</span>
|
|
<input
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
max="1"
|
|
step="0.05"
|
|
value={
|
|
editorState.document.world.shaderSky.clouds
|
|
.opacity
|
|
}
|
|
onChange={(event) =>
|
|
applyShaderSkyNumericSetting(
|
|
"Set shader sky cloud opacity",
|
|
"Updated the shader sky cloud opacity.",
|
|
event.currentTarget.valueAsNumber,
|
|
(shaderSky, nextValue) => {
|
|
shaderSky.clouds.opacity = nextValue;
|
|
}
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Opacity Randomness</span>
|
|
<input
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
max="1"
|
|
step="0.05"
|
|
value={
|
|
editorState.document.world.shaderSky.clouds
|
|
.opacityRandomness
|
|
}
|
|
onChange={(event) =>
|
|
applyShaderSkyNumericSetting(
|
|
"Set shader sky cloud opacity randomness",
|
|
"Updated the shader sky cloud opacity randomness.",
|
|
event.currentTarget.valueAsNumber,
|
|
(shaderSky, nextValue) => {
|
|
shaderSky.clouds.opacityRandomness =
|
|
nextValue;
|
|
}
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Drift Speed</span>
|
|
<input
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
step="0.01"
|
|
value={
|
|
editorState.document.world.shaderSky.clouds
|
|
.driftSpeed
|
|
}
|
|
onChange={(event) =>
|
|
applyShaderSkyNumericSetting(
|
|
"Set shader sky cloud drift speed",
|
|
"Updated the shader sky cloud drift speed.",
|
|
event.currentTarget.valueAsNumber,
|
|
(shaderSky, nextValue) => {
|
|
shaderSky.clouds.driftSpeed = nextValue;
|
|
}
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Drift Direction</span>
|
|
<input
|
|
className="text-input"
|
|
type="number"
|
|
step="1"
|
|
value={
|
|
editorState.document.world.shaderSky.clouds
|
|
.driftDirectionDegrees
|
|
}
|
|
onChange={(event) =>
|
|
applyShaderSkyNumericSetting(
|
|
"Set shader sky cloud drift direction",
|
|
"Updated the shader sky cloud drift direction.",
|
|
event.currentTarget.valueAsNumber,
|
|
(shaderSky, nextValue) => {
|
|
shaderSky.clouds.driftDirectionDegrees =
|
|
nextValue;
|
|
}
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<div className="form-section">
|
|
<div className="label">Ambient Light</div>
|
|
<div className="vector-inputs vector-inputs--two">
|
|
<label className="form-field">
|
|
<span className="label">Color</span>
|
|
<input
|
|
data-testid="world-ambient-color"
|
|
className="color-input"
|
|
type="color"
|
|
value={
|
|
editorState.document.world.ambientLight.colorHex
|
|
}
|
|
onChange={(event) =>
|
|
applyAmbientLightColor(event.currentTarget.value)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Intensity</span>
|
|
<input
|
|
data-testid="world-ambient-intensity"
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
step="0.1"
|
|
value={ambientLightIntensityDraft}
|
|
onChange={(event) =>
|
|
setAmbientLightIntensityDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={applyAmbientLightIntensity}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyAmbientLightIntensity
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyAmbientLightIntensity
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyAmbientLightIntensity
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Celestial Orbits</div>
|
|
<div className="material-summary">
|
|
These angles drive the time-aware sun and moon paths in
|
|
the editor viewport and runner. Base peak altitude is the
|
|
12-hour reference height; longer or shorter day/night
|
|
windows shift the orbit automatically.
|
|
</div>
|
|
<div className="vector-inputs vector-inputs--two">
|
|
<label className="form-field">
|
|
<span className="label">Sun Azimuth</span>
|
|
<input
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
max="360"
|
|
step="1"
|
|
value={
|
|
editorState.document.world.celestialOrbits.sun
|
|
.azimuthDegrees
|
|
}
|
|
onChange={(event) =>
|
|
applyCelestialOrbitNumericSetting(
|
|
"Set sun orbit azimuth",
|
|
"Updated the sun orbit azimuth.",
|
|
event.currentTarget.valueAsNumber,
|
|
(world, nextValue) => {
|
|
world.celestialOrbits.sun.azimuthDegrees =
|
|
nextValue;
|
|
}
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Sun Base Peak Altitude</span>
|
|
<input
|
|
className="text-input"
|
|
type="number"
|
|
min="0.1"
|
|
max="89.9"
|
|
step="0.1"
|
|
value={
|
|
editorState.document.world.celestialOrbits.sun
|
|
.peakAltitudeDegrees
|
|
}
|
|
onChange={(event) =>
|
|
applyCelestialOrbitNumericSetting(
|
|
"Set sun orbit peak altitude",
|
|
"Updated the sun orbit peak altitude.",
|
|
event.currentTarget.valueAsNumber,
|
|
(world, nextValue) => {
|
|
world.celestialOrbits.sun.peakAltitudeDegrees =
|
|
nextValue;
|
|
}
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Moon Azimuth</span>
|
|
<input
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
max="360"
|
|
step="1"
|
|
value={
|
|
editorState.document.world.celestialOrbits.moon
|
|
.azimuthDegrees
|
|
}
|
|
onChange={(event) =>
|
|
applyCelestialOrbitNumericSetting(
|
|
"Set moon orbit azimuth",
|
|
"Updated the moon orbit azimuth.",
|
|
event.currentTarget.valueAsNumber,
|
|
(world, nextValue) => {
|
|
world.celestialOrbits.moon.azimuthDegrees =
|
|
nextValue;
|
|
}
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Moon Base Peak Altitude</span>
|
|
<input
|
|
className="text-input"
|
|
type="number"
|
|
min="0.1"
|
|
max="89.9"
|
|
step="0.1"
|
|
value={
|
|
editorState.document.world.celestialOrbits.moon
|
|
.peakAltitudeDegrees
|
|
}
|
|
onChange={(event) =>
|
|
applyCelestialOrbitNumericSetting(
|
|
"Set moon orbit peak altitude",
|
|
"Updated the moon orbit peak altitude.",
|
|
event.currentTarget.valueAsNumber,
|
|
(world, nextValue) => {
|
|
world.celestialOrbits.moon.peakAltitudeDegrees =
|
|
nextValue;
|
|
}
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Sun Light</div>
|
|
<div className="vector-inputs vector-inputs--two">
|
|
<label className="form-field">
|
|
<span className="label">Color</span>
|
|
<input
|
|
data-testid="world-sun-color"
|
|
className="color-input"
|
|
type="color"
|
|
value={editorState.document.world.sunLight.colorHex}
|
|
onChange={(event) =>
|
|
applySunLightColor(event.currentTarget.value)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Intensity</span>
|
|
<input
|
|
data-testid="world-sun-intensity"
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
step="0.1"
|
|
value={sunLightIntensityDraft}
|
|
onChange={(event) =>
|
|
setSunLightIntensityDraft(event.currentTarget.value)
|
|
}
|
|
onBlur={applySunLightIntensity}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applySunLightIntensity
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applySunLightIntensity
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applySunLightIntensity
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</Panel>
|
|
|
|
{!editorState.document.world
|
|
.projectTimeLightingEnabled ? null : (
|
|
<>
|
|
<Panel title="Dawn Override" defaultExpanded={false}>
|
|
<div className="material-summary">
|
|
Blends over the authored day environment around sunrise.
|
|
</div>
|
|
<div className="stat-card">
|
|
<div className="label">Background</div>
|
|
<div className="value">
|
|
{formatWorldBackgroundLabel(
|
|
editorState.document.world.timeOfDay.dawn.background
|
|
)}
|
|
</div>
|
|
<div
|
|
className="world-background-preview"
|
|
style={createWorldBackgroundStyle(
|
|
editorState.document.world.timeOfDay.dawn
|
|
.background,
|
|
getWorldBackgroundImagePreviewUrl(
|
|
editorState.document.world.timeOfDay.dawn
|
|
.background,
|
|
loadedImageAssets
|
|
)
|
|
)}
|
|
/>
|
|
<div className="material-summary">
|
|
{describeWorldBackground(
|
|
editorState.document.world.timeOfDay.dawn
|
|
.background,
|
|
editorState.document.assets,
|
|
{
|
|
emptyImageLabel:
|
|
"Automatic fallback from available day/night images"
|
|
}
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Background Mode</div>
|
|
<div className="inline-actions">
|
|
<button
|
|
className={`toolbar__button ${editorState.document.world.timeOfDay.dawn.background.mode === "solid" ? "toolbar__button--active" : ""}`}
|
|
type="button"
|
|
onClick={() =>
|
|
applyWorldTimePhaseBackgroundMode("dawn", "solid")
|
|
}
|
|
>
|
|
Solid
|
|
</button>
|
|
<button
|
|
className={`toolbar__button ${
|
|
editorState.document.world.timeOfDay.dawn
|
|
.background.mode === "verticalGradient"
|
|
? "toolbar__button--active"
|
|
: ""
|
|
}`}
|
|
type="button"
|
|
onClick={() =>
|
|
applyWorldTimePhaseBackgroundMode(
|
|
"dawn",
|
|
"verticalGradient"
|
|
)
|
|
}
|
|
>
|
|
Gradient
|
|
</button>
|
|
<button
|
|
className={`toolbar__button ${editorState.document.world.timeOfDay.dawn.background.mode === "image" ? "toolbar__button--active" : ""}`}
|
|
type="button"
|
|
onClick={() =>
|
|
applyWorldTimePhaseBackgroundMode("dawn", "image")
|
|
}
|
|
>
|
|
Image
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{editorState.document.world.timeOfDay.dawn.background
|
|
.mode === "image" ? (
|
|
<div className="form-section">
|
|
<div className="label">Dawn Image</div>
|
|
<label className="form-field">
|
|
<span className="label">Image</span>
|
|
<select
|
|
className="text-input"
|
|
value={
|
|
editorState.document.world.timeOfDay.dawn
|
|
.background.assetId
|
|
}
|
|
onChange={(event) =>
|
|
applyWorldTimePhaseBackgroundMode(
|
|
"dawn",
|
|
"image",
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
>
|
|
<option value="">
|
|
Auto (day/night fallback)
|
|
</option>
|
|
{imageAssetList.map((asset) => (
|
|
<option key={asset.id} value={asset.id}>
|
|
{asset.sourceName}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Intensity</span>
|
|
<input
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
step="0.1"
|
|
value={
|
|
worldDawnBackgroundEnvironmentIntensityDraft
|
|
}
|
|
onChange={(event) =>
|
|
setWorldDawnBackgroundEnvironmentIntensityDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={() =>
|
|
applyWorldTimePhaseBackgroundEnvironmentIntensity(
|
|
"dawn",
|
|
worldDawnBackgroundEnvironmentIntensityDraft
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
) : (
|
|
<div className="form-section">
|
|
<div className="label">Background Colors</div>
|
|
{editorState.document.world.timeOfDay.dawn.background
|
|
.mode === "solid" ? (
|
|
<label className="form-field">
|
|
<span className="label">Color</span>
|
|
<input
|
|
className="color-input"
|
|
type="color"
|
|
value={
|
|
editorState.document.world.timeOfDay.dawn
|
|
.background.colorHex
|
|
}
|
|
onChange={(event) =>
|
|
applyWorldTimePhaseBackgroundColor(
|
|
"dawn",
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
) : editorState.document.world.timeOfDay.dawn
|
|
.background.mode === "verticalGradient" ? (
|
|
<div className="vector-inputs vector-inputs--two">
|
|
<label className="form-field">
|
|
<span className="label">Top</span>
|
|
<input
|
|
className="color-input"
|
|
type="color"
|
|
value={
|
|
editorState.document.world.timeOfDay.dawn
|
|
.background.topColorHex
|
|
}
|
|
onChange={(event) =>
|
|
applyWorldTimePhaseGradientColor(
|
|
"dawn",
|
|
"top",
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Bottom</span>
|
|
<input
|
|
className="color-input"
|
|
type="color"
|
|
value={
|
|
editorState.document.world.timeOfDay.dawn
|
|
.background.bottomColorHex
|
|
}
|
|
onChange={(event) =>
|
|
applyWorldTimePhaseGradientColor(
|
|
"dawn",
|
|
"bottom",
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
<div className="vector-inputs">
|
|
<label className="form-field">
|
|
<span className="label">Ambient Color</span>
|
|
<input
|
|
className="color-input"
|
|
type="color"
|
|
value={
|
|
editorState.document.world.timeOfDay.dawn
|
|
.ambientColorHex
|
|
}
|
|
onChange={(event) =>
|
|
applyWorldTimePhaseColor(
|
|
"dawn",
|
|
"ambientColorHex",
|
|
event.currentTarget.value,
|
|
"Set dawn ambient color",
|
|
"Updated the dawn ambient color."
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Ambient Factor</span>
|
|
<input
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
step="0.05"
|
|
value={worldDawnAmbientIntensityFactorDraft}
|
|
onChange={(event) =>
|
|
setWorldDawnAmbientIntensityFactorDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={() =>
|
|
applyWorldTimePhaseNumericField(
|
|
"dawn",
|
|
"ambientIntensityFactor",
|
|
worldDawnAmbientIntensityFactorDraft,
|
|
"Set dawn ambient factor",
|
|
"Dawn ambient factor",
|
|
"Updated the dawn ambient factor."
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Light Color</span>
|
|
<input
|
|
className="color-input"
|
|
type="color"
|
|
value={
|
|
editorState.document.world.timeOfDay.dawn
|
|
.lightColorHex
|
|
}
|
|
onChange={(event) =>
|
|
applyWorldTimePhaseColor(
|
|
"dawn",
|
|
"lightColorHex",
|
|
event.currentTarget.value,
|
|
"Set dawn light color",
|
|
"Updated the dawn light color."
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Light Factor</span>
|
|
<input
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
step="0.05"
|
|
value={worldDawnLightIntensityFactorDraft}
|
|
onChange={(event) =>
|
|
setWorldDawnLightIntensityFactorDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={() =>
|
|
applyWorldTimePhaseNumericField(
|
|
"dawn",
|
|
"lightIntensityFactor",
|
|
worldDawnLightIntensityFactorDraft,
|
|
"Set dawn light factor",
|
|
"Dawn light factor",
|
|
"Updated the dawn light factor."
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</Panel>
|
|
|
|
<Panel title="Dusk Override" defaultExpanded={false}>
|
|
<div className="material-summary">
|
|
Blends over the authored day environment around sunset.
|
|
</div>
|
|
<div className="stat-card">
|
|
<div className="label">Background</div>
|
|
<div className="value">
|
|
{formatWorldBackgroundLabel(
|
|
editorState.document.world.timeOfDay.dusk.background
|
|
)}
|
|
</div>
|
|
<div
|
|
className="world-background-preview"
|
|
style={createWorldBackgroundStyle(
|
|
editorState.document.world.timeOfDay.dusk
|
|
.background,
|
|
getWorldBackgroundImagePreviewUrl(
|
|
editorState.document.world.timeOfDay.dusk
|
|
.background,
|
|
loadedImageAssets
|
|
)
|
|
)}
|
|
/>
|
|
<div className="material-summary">
|
|
{describeWorldBackground(
|
|
editorState.document.world.timeOfDay.dusk
|
|
.background,
|
|
editorState.document.assets,
|
|
{
|
|
emptyImageLabel:
|
|
"Automatic fallback from available day/night images"
|
|
}
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Background Mode</div>
|
|
<div className="inline-actions">
|
|
<button
|
|
className={`toolbar__button ${editorState.document.world.timeOfDay.dusk.background.mode === "solid" ? "toolbar__button--active" : ""}`}
|
|
type="button"
|
|
onClick={() =>
|
|
applyWorldTimePhaseBackgroundMode("dusk", "solid")
|
|
}
|
|
>
|
|
Solid
|
|
</button>
|
|
<button
|
|
className={`toolbar__button ${
|
|
editorState.document.world.timeOfDay.dusk
|
|
.background.mode === "verticalGradient"
|
|
? "toolbar__button--active"
|
|
: ""
|
|
}`}
|
|
type="button"
|
|
onClick={() =>
|
|
applyWorldTimePhaseBackgroundMode(
|
|
"dusk",
|
|
"verticalGradient"
|
|
)
|
|
}
|
|
>
|
|
Gradient
|
|
</button>
|
|
<button
|
|
className={`toolbar__button ${editorState.document.world.timeOfDay.dusk.background.mode === "image" ? "toolbar__button--active" : ""}`}
|
|
type="button"
|
|
onClick={() =>
|
|
applyWorldTimePhaseBackgroundMode("dusk", "image")
|
|
}
|
|
>
|
|
Image
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{editorState.document.world.timeOfDay.dusk.background
|
|
.mode === "image" ? (
|
|
<div className="form-section">
|
|
<div className="label">Dusk Image</div>
|
|
<label className="form-field">
|
|
<span className="label">Image</span>
|
|
<select
|
|
className="text-input"
|
|
value={
|
|
editorState.document.world.timeOfDay.dusk
|
|
.background.assetId
|
|
}
|
|
onChange={(event) =>
|
|
applyWorldTimePhaseBackgroundMode(
|
|
"dusk",
|
|
"image",
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
>
|
|
<option value="">
|
|
Auto (day/night fallback)
|
|
</option>
|
|
{imageAssetList.map((asset) => (
|
|
<option key={asset.id} value={asset.id}>
|
|
{asset.sourceName}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Intensity</span>
|
|
<input
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
step="0.1"
|
|
value={
|
|
worldDuskBackgroundEnvironmentIntensityDraft
|
|
}
|
|
onChange={(event) =>
|
|
setWorldDuskBackgroundEnvironmentIntensityDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={() =>
|
|
applyWorldTimePhaseBackgroundEnvironmentIntensity(
|
|
"dusk",
|
|
worldDuskBackgroundEnvironmentIntensityDraft
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
) : (
|
|
<div className="form-section">
|
|
<div className="label">Background Colors</div>
|
|
{editorState.document.world.timeOfDay.dusk.background
|
|
.mode === "solid" ? (
|
|
<label className="form-field">
|
|
<span className="label">Color</span>
|
|
<input
|
|
className="color-input"
|
|
type="color"
|
|
value={
|
|
editorState.document.world.timeOfDay.dusk
|
|
.background.colorHex
|
|
}
|
|
onChange={(event) =>
|
|
applyWorldTimePhaseBackgroundColor(
|
|
"dusk",
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
) : editorState.document.world.timeOfDay.dusk
|
|
.background.mode === "verticalGradient" ? (
|
|
<div className="vector-inputs vector-inputs--two">
|
|
<label className="form-field">
|
|
<span className="label">Top</span>
|
|
<input
|
|
className="color-input"
|
|
type="color"
|
|
value={
|
|
editorState.document.world.timeOfDay.dusk
|
|
.background.topColorHex
|
|
}
|
|
onChange={(event) =>
|
|
applyWorldTimePhaseGradientColor(
|
|
"dusk",
|
|
"top",
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Bottom</span>
|
|
<input
|
|
className="color-input"
|
|
type="color"
|
|
value={
|
|
editorState.document.world.timeOfDay.dusk
|
|
.background.bottomColorHex
|
|
}
|
|
onChange={(event) =>
|
|
applyWorldTimePhaseGradientColor(
|
|
"dusk",
|
|
"bottom",
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
<div className="vector-inputs">
|
|
<label className="form-field">
|
|
<span className="label">Ambient Color</span>
|
|
<input
|
|
className="color-input"
|
|
type="color"
|
|
value={
|
|
editorState.document.world.timeOfDay.dusk
|
|
.ambientColorHex
|
|
}
|
|
onChange={(event) =>
|
|
applyWorldTimePhaseColor(
|
|
"dusk",
|
|
"ambientColorHex",
|
|
event.currentTarget.value,
|
|
"Set dusk ambient color",
|
|
"Updated the dusk ambient color."
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Ambient Factor</span>
|
|
<input
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
step="0.05"
|
|
value={worldDuskAmbientIntensityFactorDraft}
|
|
onChange={(event) =>
|
|
setWorldDuskAmbientIntensityFactorDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={() =>
|
|
applyWorldTimePhaseNumericField(
|
|
"dusk",
|
|
"ambientIntensityFactor",
|
|
worldDuskAmbientIntensityFactorDraft,
|
|
"Set dusk ambient factor",
|
|
"Dusk ambient factor",
|
|
"Updated the dusk ambient factor."
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Light Color</span>
|
|
<input
|
|
className="color-input"
|
|
type="color"
|
|
value={
|
|
editorState.document.world.timeOfDay.dusk
|
|
.lightColorHex
|
|
}
|
|
onChange={(event) =>
|
|
applyWorldTimePhaseColor(
|
|
"dusk",
|
|
"lightColorHex",
|
|
event.currentTarget.value,
|
|
"Set dusk light color",
|
|
"Updated the dusk light color."
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Light Factor</span>
|
|
<input
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
step="0.05"
|
|
value={worldDuskLightIntensityFactorDraft}
|
|
onChange={(event) =>
|
|
setWorldDuskLightIntensityFactorDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={() =>
|
|
applyWorldTimePhaseNumericField(
|
|
"dusk",
|
|
"lightIntensityFactor",
|
|
worldDuskLightIntensityFactorDraft,
|
|
"Set dusk light factor",
|
|
"Dusk light factor",
|
|
"Updated the dusk light factor."
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</Panel>
|
|
|
|
<Panel title="Night Environment" defaultExpanded={false}>
|
|
<div className="stat-card">
|
|
<div className="label">Background</div>
|
|
<div className="value">
|
|
{formatWorldBackgroundLabel(
|
|
editorState.document.world.timeOfDay.night
|
|
.background
|
|
)}
|
|
</div>
|
|
<div
|
|
className="world-background-preview"
|
|
data-testid="world-night-background-preview"
|
|
style={createWorldBackgroundStyle(
|
|
editorState.document.world.timeOfDay.night
|
|
.background,
|
|
editorState.document.world.timeOfDay.night
|
|
.background.mode === "image"
|
|
? (loadedImageAssets[
|
|
editorState.document.world.timeOfDay.night
|
|
.background.assetId
|
|
]?.previewUrl ?? null)
|
|
: null
|
|
)}
|
|
/>
|
|
<div className="material-summary">
|
|
{describeWorldBackground(
|
|
editorState.document.world.timeOfDay.night
|
|
.background,
|
|
editorState.document.assets
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Background Mode</div>
|
|
<div className="inline-actions">
|
|
<button
|
|
className={`toolbar__button ${editorState.document.world.timeOfDay.night.background.mode === "solid" ? "toolbar__button--active" : ""}`}
|
|
type="button"
|
|
onClick={() => applyNightBackgroundMode("solid")}
|
|
>
|
|
Solid
|
|
</button>
|
|
<button
|
|
className={`toolbar__button ${
|
|
editorState.document.world.timeOfDay.night
|
|
.background.mode === "verticalGradient"
|
|
? "toolbar__button--active"
|
|
: ""
|
|
}`}
|
|
type="button"
|
|
onClick={() =>
|
|
applyNightBackgroundMode("verticalGradient")
|
|
}
|
|
>
|
|
Gradient
|
|
</button>
|
|
<button
|
|
className={`toolbar__button ${editorState.document.world.timeOfDay.night.background.mode === "image" ? "toolbar__button--active" : ""}`}
|
|
type="button"
|
|
onClick={() => applyNightBackgroundMode("image")}
|
|
>
|
|
Image
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{editorState.document.world.timeOfDay.night.background
|
|
.mode === "image" ? (
|
|
<div className="form-section">
|
|
<div className="label">Night Image</div>
|
|
<label className="form-field">
|
|
<span className="label">Image</span>
|
|
<select
|
|
data-testid="world-night-background-asset-select"
|
|
className="text-input"
|
|
value={
|
|
editorState.document.world.timeOfDay.night
|
|
.background.assetId
|
|
}
|
|
onChange={(event) =>
|
|
applyNightBackgroundMode(
|
|
"image",
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
>
|
|
{imageAssetList.map((asset) => (
|
|
<option key={asset.id} value={asset.id}>
|
|
{asset.sourceName}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Intensity</span>
|
|
<input
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
step="0.1"
|
|
value={
|
|
worldNightBackgroundEnvironmentIntensityDraft
|
|
}
|
|
onChange={(event) =>
|
|
setWorldNightBackgroundEnvironmentIntensityDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={applyNightBackgroundEnvironmentIntensity}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyNightBackgroundEnvironmentIntensity
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyNightBackgroundEnvironmentIntensity
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyNightBackgroundEnvironmentIntensity
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
) : (
|
|
<div className="form-section">
|
|
<div className="label">Background Colors</div>
|
|
{editorState.document.world.timeOfDay.night.background
|
|
.mode === "solid" ? (
|
|
<label className="form-field">
|
|
<span className="label">Color</span>
|
|
<input
|
|
className="color-input"
|
|
type="color"
|
|
value={
|
|
editorState.document.world.timeOfDay.night
|
|
.background.colorHex
|
|
}
|
|
onChange={(event) =>
|
|
applyNightBackgroundColor(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
) : editorState.document.world.timeOfDay.night
|
|
.background.mode === "verticalGradient" ? (
|
|
<div className="vector-inputs vector-inputs--two">
|
|
<label className="form-field">
|
|
<span className="label">Top</span>
|
|
<input
|
|
className="color-input"
|
|
type="color"
|
|
value={
|
|
editorState.document.world.timeOfDay.night
|
|
.background.topColorHex
|
|
}
|
|
onChange={(event) =>
|
|
applyNightGradientColor(
|
|
"top",
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Bottom</span>
|
|
<input
|
|
className="color-input"
|
|
type="color"
|
|
value={
|
|
editorState.document.world.timeOfDay.night
|
|
.background.bottomColorHex
|
|
}
|
|
onChange={(event) =>
|
|
applyNightGradientColor(
|
|
"bottom",
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
|
|
<div className="vector-inputs">
|
|
<label className="form-field">
|
|
<span className="label">Ambient Color</span>
|
|
<input
|
|
className="color-input"
|
|
type="color"
|
|
value={
|
|
editorState.document.world.timeOfDay.night
|
|
.ambientColorHex
|
|
}
|
|
onChange={(event) =>
|
|
applyWorldNightEnvironmentColor(
|
|
"ambientColorHex",
|
|
event.currentTarget.value,
|
|
"Set night ambient color",
|
|
"Updated the night ambient color."
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Ambient Factor</span>
|
|
<input
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
step="0.05"
|
|
value={worldNightAmbientIntensityFactorDraft}
|
|
onChange={(event) =>
|
|
setWorldNightAmbientIntensityFactorDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={() =>
|
|
applyWorldNightEnvironmentNumericField(
|
|
"ambientIntensityFactor",
|
|
worldNightAmbientIntensityFactorDraft,
|
|
"Set night ambient factor",
|
|
"Night ambient factor",
|
|
"Updated the night ambient factor."
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Moon / Night Light</span>
|
|
<input
|
|
className="color-input"
|
|
type="color"
|
|
value={
|
|
editorState.document.world.timeOfDay.night
|
|
.lightColorHex
|
|
}
|
|
onChange={(event) =>
|
|
applyWorldNightEnvironmentColor(
|
|
"lightColorHex",
|
|
event.currentTarget.value,
|
|
"Set night light color",
|
|
"Updated the night light color."
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Moon / Night Factor</span>
|
|
<input
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
step="0.05"
|
|
value={worldNightLightIntensityFactorDraft}
|
|
onChange={(event) =>
|
|
setWorldNightLightIntensityFactorDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={() =>
|
|
applyWorldNightEnvironmentNumericField(
|
|
"lightIntensityFactor",
|
|
worldNightLightIntensityFactorDraft,
|
|
"Set night light factor",
|
|
"Night light factor",
|
|
"Updated the night light factor."
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</Panel>
|
|
</>
|
|
)}
|
|
|
|
<Panel title="Advanced Rendering" defaultExpanded={false}>
|
|
<div className="form-section">
|
|
<label className="form-field form-field--toggle">
|
|
<span className="label">Advanced Rendering</span>
|
|
<input
|
|
type="checkbox"
|
|
checked={advancedRendering.enabled}
|
|
onChange={(event) =>
|
|
applyAdvancedRenderingEnabled(
|
|
event.currentTarget.checked
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
|
|
{!advancedRendering.enabled ? null : (
|
|
<>
|
|
<div className="form-section">
|
|
<div className="label">Shadows</div>
|
|
<label className="form-field form-field--toggle">
|
|
<span className="label">Enabled</span>
|
|
<input
|
|
type="checkbox"
|
|
checked={advancedRendering.shadows.enabled}
|
|
onChange={(event) =>
|
|
applyAdvancedRenderingShadowsEnabled(
|
|
event.currentTarget.checked
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<div className="vector-inputs vector-inputs--two">
|
|
<label className="form-field">
|
|
<span className="label">Shadow Map Size</span>
|
|
<select
|
|
className="select-input"
|
|
value={advancedRendering.shadows.mapSize}
|
|
onChange={(event) =>
|
|
applyAdvancedRenderingShadowMapSize(
|
|
Number(
|
|
event.currentTarget.value
|
|
) as AdvancedRenderingShadowMapSize
|
|
)
|
|
}
|
|
>
|
|
{ADVANCED_RENDERING_SHADOW_MAP_SIZES.map(
|
|
(size) => (
|
|
<option key={size} value={size}>
|
|
{size}
|
|
</option>
|
|
)
|
|
)}
|
|
</select>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Shadow Type</span>
|
|
<select
|
|
className="select-input"
|
|
value={advancedRendering.shadows.type}
|
|
onChange={(event) =>
|
|
applyAdvancedRenderingShadowType(
|
|
event.currentTarget
|
|
.value as AdvancedRenderingShadowType
|
|
)
|
|
}
|
|
>
|
|
{ADVANCED_RENDERING_SHADOW_TYPES.map(
|
|
(shadowType) => (
|
|
<option key={shadowType} value={shadowType}>
|
|
{formatAdvancedRenderingShadowTypeLabel(
|
|
shadowType
|
|
)}
|
|
</option>
|
|
)
|
|
)}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<label className="form-field">
|
|
<span className="label">Bias</span>
|
|
<input
|
|
className="text-input"
|
|
type="number"
|
|
step="0.0001"
|
|
value={advancedRenderingShadowBiasDraft}
|
|
onChange={(event) =>
|
|
setAdvancedRenderingShadowBiasDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={applyAdvancedRenderingShadowBias}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyAdvancedRenderingShadowBias
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyAdvancedRenderingShadowBias
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyAdvancedRenderingShadowBias
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Ambient Occlusion</div>
|
|
<label className="form-field form-field--toggle">
|
|
<span className="label">Enabled</span>
|
|
<input
|
|
type="checkbox"
|
|
checked={
|
|
advancedRendering.ambientOcclusion.enabled
|
|
}
|
|
onChange={(event) =>
|
|
applyAdvancedRenderingAmbientOcclusionEnabled(
|
|
event.currentTarget.checked
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<div className="vector-inputs vector-inputs--two">
|
|
<label className="form-field">
|
|
<span className="label">Intensity</span>
|
|
<input
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
step="0.1"
|
|
value={
|
|
advancedRenderingAmbientOcclusionIntensityDraft
|
|
}
|
|
onChange={(event) =>
|
|
setAdvancedRenderingAmbientOcclusionIntensityDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={
|
|
applyAdvancedRenderingAmbientOcclusionIntensity
|
|
}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyAdvancedRenderingAmbientOcclusionIntensity
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyAdvancedRenderingAmbientOcclusionIntensity
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyAdvancedRenderingAmbientOcclusionIntensity
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Radius</span>
|
|
<input
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
step="0.1"
|
|
value={
|
|
advancedRenderingAmbientOcclusionRadiusDraft
|
|
}
|
|
onChange={(event) =>
|
|
setAdvancedRenderingAmbientOcclusionRadiusDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={
|
|
applyAdvancedRenderingAmbientOcclusionRadius
|
|
}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyAdvancedRenderingAmbientOcclusionRadius
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyAdvancedRenderingAmbientOcclusionRadius
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyAdvancedRenderingAmbientOcclusionRadius
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
<label className="form-field">
|
|
<span className="label">Samples</span>
|
|
<input
|
|
className="text-input"
|
|
type="number"
|
|
min="1"
|
|
step="1"
|
|
value={
|
|
advancedRenderingAmbientOcclusionSamplesDraft
|
|
}
|
|
onChange={(event) =>
|
|
setAdvancedRenderingAmbientOcclusionSamplesDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={
|
|
applyAdvancedRenderingAmbientOcclusionSamples
|
|
}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyAdvancedRenderingAmbientOcclusionSamples
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyAdvancedRenderingAmbientOcclusionSamples
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyAdvancedRenderingAmbientOcclusionSamples
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Bloom</div>
|
|
<label className="form-field form-field--toggle">
|
|
<span className="label">Enabled</span>
|
|
<input
|
|
type="checkbox"
|
|
checked={advancedRendering.bloom.enabled}
|
|
onChange={(event) =>
|
|
applyAdvancedRenderingBloomEnabled(
|
|
event.currentTarget.checked
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<div className="vector-inputs vector-inputs--two">
|
|
<label className="form-field">
|
|
<span className="label">Intensity</span>
|
|
<input
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
step="0.1"
|
|
value={advancedRenderingBloomIntensityDraft}
|
|
onChange={(event) =>
|
|
setAdvancedRenderingBloomIntensityDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={applyAdvancedRenderingBloomIntensity}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyAdvancedRenderingBloomIntensity
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyAdvancedRenderingBloomIntensity
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyAdvancedRenderingBloomIntensity
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Threshold</span>
|
|
<input
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
step="0.05"
|
|
value={advancedRenderingBloomThresholdDraft}
|
|
onChange={(event) =>
|
|
setAdvancedRenderingBloomThresholdDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={applyAdvancedRenderingBloomThreshold}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyAdvancedRenderingBloomThreshold
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyAdvancedRenderingBloomThreshold
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyAdvancedRenderingBloomThreshold
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
<label className="form-field">
|
|
<span className="label">Radius</span>
|
|
<input
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
step="0.05"
|
|
value={advancedRenderingBloomRadiusDraft}
|
|
onChange={(event) =>
|
|
setAdvancedRenderingBloomRadiusDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={applyAdvancedRenderingBloomRadius}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyAdvancedRenderingBloomRadius
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyAdvancedRenderingBloomRadius
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyAdvancedRenderingBloomRadius
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Tone Mapping</div>
|
|
<div className="vector-inputs vector-inputs--two">
|
|
<label className="form-field">
|
|
<span className="label">Mode</span>
|
|
<select
|
|
className="select-input"
|
|
value={advancedRendering.toneMapping.mode}
|
|
onChange={(event) =>
|
|
applyAdvancedRenderingToneMappingMode(
|
|
event.currentTarget
|
|
.value as AdvancedRenderingToneMappingMode
|
|
)
|
|
}
|
|
>
|
|
{ADVANCED_RENDERING_TONE_MAPPING_MODES.map(
|
|
(mode) => (
|
|
<option key={mode} value={mode}>
|
|
{formatAdvancedRenderingToneMappingLabel(
|
|
mode
|
|
)}
|
|
</option>
|
|
)
|
|
)}
|
|
</select>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Exposure</span>
|
|
<input
|
|
className="text-input"
|
|
type="number"
|
|
min="0.001"
|
|
step="0.1"
|
|
value={
|
|
advancedRenderingToneMappingExposureDraft
|
|
}
|
|
onChange={(event) =>
|
|
setAdvancedRenderingToneMappingExposureDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={
|
|
applyAdvancedRenderingToneMappingExposure
|
|
}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyAdvancedRenderingToneMappingExposure
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyAdvancedRenderingToneMappingExposure
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyAdvancedRenderingToneMappingExposure
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Depth of Field</div>
|
|
<label className="form-field form-field--toggle">
|
|
<span className="label">Enabled</span>
|
|
<input
|
|
type="checkbox"
|
|
checked={advancedRendering.depthOfField.enabled}
|
|
onChange={(event) =>
|
|
applyAdvancedRenderingDepthOfFieldEnabled(
|
|
event.currentTarget.checked
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<div className="vector-inputs vector-inputs--two">
|
|
<label className="form-field">
|
|
<span className="label">Focus Distance</span>
|
|
<input
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
step="0.1"
|
|
value={
|
|
advancedRenderingDepthOfFieldFocusDistanceDraft
|
|
}
|
|
onChange={(event) =>
|
|
setAdvancedRenderingDepthOfFieldFocusDistanceDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={
|
|
applyAdvancedRenderingDepthOfFieldFocusDistance
|
|
}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyAdvancedRenderingDepthOfFieldFocusDistance
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyAdvancedRenderingDepthOfFieldFocusDistance
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyAdvancedRenderingDepthOfFieldFocusDistance
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Focal Length</span>
|
|
<input
|
|
className="text-input"
|
|
type="number"
|
|
min="0.001"
|
|
step="0.001"
|
|
value={
|
|
advancedRenderingDepthOfFieldFocalLengthDraft
|
|
}
|
|
onChange={(event) =>
|
|
setAdvancedRenderingDepthOfFieldFocalLengthDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={
|
|
applyAdvancedRenderingDepthOfFieldFocalLength
|
|
}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyAdvancedRenderingDepthOfFieldFocalLength
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyAdvancedRenderingDepthOfFieldFocalLength
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyAdvancedRenderingDepthOfFieldFocalLength
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
<label className="form-field">
|
|
<span className="label">Bokeh Scale</span>
|
|
<input
|
|
className="text-input"
|
|
type="number"
|
|
min="0.001"
|
|
step="0.1"
|
|
value={
|
|
advancedRenderingDepthOfFieldBokehScaleDraft
|
|
}
|
|
onChange={(event) =>
|
|
setAdvancedRenderingDepthOfFieldBokehScaleDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={
|
|
applyAdvancedRenderingDepthOfFieldBokehScale
|
|
}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyAdvancedRenderingDepthOfFieldBokehScale
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyAdvancedRenderingDepthOfFieldBokehScale
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyAdvancedRenderingDepthOfFieldBokehScale
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Whitebox Bevel</div>
|
|
<label className="form-field form-field--toggle">
|
|
<span className="label">Enabled</span>
|
|
<input
|
|
type="checkbox"
|
|
checked={advancedRendering.whiteboxBevel.enabled}
|
|
onChange={(event) =>
|
|
applyAdvancedRenderingWhiteboxBevelEnabled(
|
|
event.currentTarget.checked
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<div className="vector-inputs vector-inputs--two">
|
|
<label className="form-field">
|
|
<span className="label">Edge Width</span>
|
|
<input
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
step="0.01"
|
|
value={
|
|
advancedRenderingWhiteboxBevelEdgeWidthDraft
|
|
}
|
|
onChange={(event) =>
|
|
setAdvancedRenderingWhiteboxBevelEdgeWidthDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={
|
|
applyAdvancedRenderingWhiteboxBevelEdgeWidth
|
|
}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyAdvancedRenderingWhiteboxBevelEdgeWidth
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyAdvancedRenderingWhiteboxBevelEdgeWidth
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyAdvancedRenderingWhiteboxBevelEdgeWidth
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Normal Strength</span>
|
|
<input
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
step="0.05"
|
|
value={
|
|
advancedRenderingWhiteboxBevelNormalStrengthDraft
|
|
}
|
|
onChange={(event) =>
|
|
setAdvancedRenderingWhiteboxBevelNormalStrengthDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={
|
|
applyAdvancedRenderingWhiteboxBevelNormalStrength
|
|
}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyAdvancedRenderingWhiteboxBevelNormalStrength
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyAdvancedRenderingWhiteboxBevelNormalStrength
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyAdvancedRenderingWhiteboxBevelNormalStrength
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Volume Rendering Paths</div>
|
|
<div className="vector-inputs vector-inputs--two">
|
|
<label className="form-field">
|
|
<span className="label">Fog</span>
|
|
<select
|
|
className="select-input"
|
|
value={advancedRendering.fogPath}
|
|
onChange={(event) =>
|
|
applyAdvancedRenderingFogPath(
|
|
event.currentTarget
|
|
.value as BoxVolumeRenderPath
|
|
)
|
|
}
|
|
>
|
|
{BOX_VOLUME_RENDER_PATHS.map((path) => (
|
|
<option key={path} value={path}>
|
|
{formatBoxVolumeRenderPathLabel(path)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Water</span>
|
|
<select
|
|
className="select-input"
|
|
value={advancedRendering.waterPath}
|
|
onChange={(event) =>
|
|
applyAdvancedRenderingWaterPath(
|
|
event.currentTarget
|
|
.value as BoxVolumeRenderPath
|
|
)
|
|
}
|
|
>
|
|
{BOX_VOLUME_RENDER_PATHS.map((path) => (
|
|
<option key={path} value={path}>
|
|
{formatBoxVolumeRenderPathLabel(path)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
{advancedRendering.waterPath === "quality" ? (
|
|
<label className="form-field">
|
|
<span className="label">Water Reflection</span>
|
|
<select
|
|
data-testid="advanced-rendering-water-reflection-mode"
|
|
className="select-input"
|
|
value={advancedRendering.waterReflectionMode}
|
|
onChange={(event) =>
|
|
applyAdvancedRenderingWaterReflectionMode(
|
|
event.currentTarget
|
|
.value as AdvancedRenderingWaterReflectionMode
|
|
)
|
|
}
|
|
>
|
|
{ADVANCED_RENDERING_WATER_REFLECTION_MODES.map(
|
|
(mode) => (
|
|
<option key={mode} value={mode}>
|
|
{formatAdvancedRenderingWaterReflectionModeLabel(
|
|
mode
|
|
)}
|
|
</option>
|
|
)
|
|
)}
|
|
</select>
|
|
</label>
|
|
) : null}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</Panel>
|
|
</Panel>
|
|
</>
|
|
) : (
|
|
<Panel title="Inspector">
|
|
<div className="stat-card">
|
|
<div className="label">Selection</div>
|
|
<div className="value">
|
|
{describeSelection(
|
|
editorState.selection,
|
|
brushList,
|
|
terrainList,
|
|
pathList,
|
|
editorState.document.modelInstances,
|
|
editorState.document.assets,
|
|
editorState.document.entities,
|
|
editorState.activeSelectionId
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{multiSelectionSummary !== null ? (
|
|
<>
|
|
<div className="stat-card">
|
|
<div className="label">Selection Kind</div>
|
|
<div className="value">
|
|
{multiSelectionSummary.kindLabel}
|
|
</div>
|
|
<div className="material-summary">
|
|
{multiSelectionSummary.count} selected
|
|
</div>
|
|
</div>
|
|
|
|
<div className="stat-card">
|
|
<div className="label">Active Item</div>
|
|
<div className="value">
|
|
{multiSelectionSummary.activeLabel}
|
|
</div>
|
|
<div className="material-summary">
|
|
{multiSelectionSummary.activeId}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Selected Items</div>
|
|
<div className="material-summary">
|
|
Batch transform, duplicate, and delete stay available.
|
|
Inline rename remains single-selection only.
|
|
</div>
|
|
<div className="outliner-list">
|
|
{multiSelectionSummary.selectedItems.map((item) => (
|
|
<div
|
|
key={item.id}
|
|
className={`outliner-item outliner-item--compact ${item.id === multiSelectionSummary.activeId ? "outliner-item--active" : ""}`}
|
|
>
|
|
<div className="outliner-item__row">
|
|
<div className="outliner-item__select">
|
|
<span className="outliner-item__title">
|
|
{item.label}
|
|
</span>
|
|
<span className="outliner-item__meta">
|
|
{item.id === multiSelectionSummary.activeId
|
|
? "Active"
|
|
: "Selected"}
|
|
</span>
|
|
</div>
|
|
<span className="outliner-item__meta">
|
|
{item.id}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : selectedTerrain !== null ? (
|
|
<>
|
|
<div className="stat-card">
|
|
<div className="label">Terrain</div>
|
|
<div className="value">
|
|
{getTerrainLabelById(selectedTerrain.id, terrainList)}
|
|
</div>
|
|
<div className="material-summary">
|
|
{selectedTerrain.sampleCountX} x{" "}
|
|
{selectedTerrain.sampleCountZ} samples
|
|
{" · "}
|
|
{selectedTerrain.cellSize}m cells
|
|
</div>
|
|
<div className="material-summary">{selectedTerrain.id}</div>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Authored State</div>
|
|
<div className="material-summary">
|
|
{formatAuthoredObjectStateSummary(selectedTerrain) ??
|
|
"Visible and enabled"}
|
|
</div>
|
|
<div className="material-summary">
|
|
Terrain editing stays inspector-driven. Arm a brush here,
|
|
then drag on the selected terrain in the viewport.
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Collision</div>
|
|
<label className="form-field form-field--toggle">
|
|
<span className="label">
|
|
Enable runner heightfield collision
|
|
</span>
|
|
<input
|
|
data-testid="terrain-collision-enabled"
|
|
type="checkbox"
|
|
checked={selectedTerrain.collisionEnabled}
|
|
onChange={(event) =>
|
|
handleTerrainCollisionEnabledChange(
|
|
event.currentTarget.checked
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<div className="material-summary">
|
|
Hidden terrain keeps collision. Disabled terrain is
|
|
removed from editor picking, rendering, and runtime
|
|
collision.
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Grid Settings</div>
|
|
<div className="material-summary">
|
|
Resizing keeps the terrain centered and resamples heights
|
|
and paint across the new grid.
|
|
</div>
|
|
<div className="vector-inputs">
|
|
<label className="form-field">
|
|
<span className="label">Samples X</span>
|
|
<input
|
|
data-testid="terrain-grid-sample-count-x"
|
|
className="text-input"
|
|
type="number"
|
|
min={MIN_TERRAIN_SAMPLE_COUNT}
|
|
step="1"
|
|
value={terrainSampleCountXDraft}
|
|
onChange={(event) =>
|
|
setTerrainSampleCountXDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={applyTerrainGridChange}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyTerrainGridChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyTerrainGridChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyTerrainGridChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Samples Z</span>
|
|
<input
|
|
data-testid="terrain-grid-sample-count-z"
|
|
className="text-input"
|
|
type="number"
|
|
min={MIN_TERRAIN_SAMPLE_COUNT}
|
|
step="1"
|
|
value={terrainSampleCountZDraft}
|
|
onChange={(event) =>
|
|
setTerrainSampleCountZDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={applyTerrainGridChange}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyTerrainGridChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyTerrainGridChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyTerrainGridChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Cell Size</span>
|
|
<input
|
|
data-testid="terrain-grid-cell-size"
|
|
className="text-input"
|
|
type="number"
|
|
min="0.1"
|
|
step="0.1"
|
|
value={terrainCellSizeDraft}
|
|
onChange={(event) =>
|
|
setTerrainCellSizeDraft(event.currentTarget.value)
|
|
}
|
|
onBlur={applyTerrainGridChange}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyTerrainGridChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyTerrainGridChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyTerrainGridChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
<button
|
|
className="viewport-panel__button"
|
|
type="button"
|
|
data-testid="terrain-grid-apply"
|
|
onClick={applyTerrainGridChange}
|
|
>
|
|
Apply Grid
|
|
</button>
|
|
{selectedTerrainFootprint === null ? null : (
|
|
<div className="material-summary">
|
|
Footprint {selectedTerrainFootprint.width}m x{" "}
|
|
{selectedTerrainFootprint.depth}m with square cells
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Terrain Sculpt</div>
|
|
<div
|
|
className="viewport-panel__control-group"
|
|
role="group"
|
|
aria-label="Terrain sculpt brush tools"
|
|
>
|
|
{TERRAIN_SCULPT_BRUSH_TOOLS.map((tool) => (
|
|
<button
|
|
key={tool}
|
|
className={`viewport-panel__button ${armedTerrainBrushTool === tool ? "viewport-panel__button--active" : ""}`}
|
|
type="button"
|
|
data-testid={`terrain-brush-tool-${tool}`}
|
|
aria-pressed={armedTerrainBrushTool === tool}
|
|
onClick={() => handleArmTerrainBrushTool(tool)}
|
|
>
|
|
{getTerrainBrushToolLabel(tool)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="material-summary">
|
|
{armedTerrainBrushTool === null
|
|
? "No terrain brush is armed. Existing selection and transforms stay unchanged."
|
|
: armedTerrainBrushTool === "paint"
|
|
? `Paint is armed for ${getTerrainLabelById(selectedTerrain.id, terrainList)} on ${getTerrainLayerLabel(resolvedTerrainPaintLayerIndex).toLowerCase()}.`
|
|
: `${getTerrainBrushToolLabel(armedTerrainBrushTool)} is armed for ${getTerrainLabelById(selectedTerrain.id, terrainList)}. Click the active tool again to disarm it.`}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Terrain Paint</div>
|
|
<div
|
|
className="viewport-panel__control-group"
|
|
role="group"
|
|
aria-label="Terrain paint controls"
|
|
>
|
|
<button
|
|
className={`viewport-panel__button ${armedTerrainBrushTool === "paint" ? "viewport-panel__button--active" : ""}`}
|
|
type="button"
|
|
data-testid="terrain-brush-tool-paint"
|
|
aria-pressed={armedTerrainBrushTool === "paint"}
|
|
onClick={() => handleArmTerrainBrushTool("paint")}
|
|
>
|
|
Paint Layer
|
|
</button>
|
|
</div>
|
|
<label className="form-field">
|
|
<span className="label">Active Layer</span>
|
|
<select
|
|
data-testid="terrain-paint-active-layer"
|
|
value={resolvedTerrainPaintLayerIndex}
|
|
onChange={(event) =>
|
|
handleTerrainPaintLayerChange(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
>
|
|
{Array.from(
|
|
{ length: TERRAIN_LAYER_COUNT },
|
|
(_, layerIndex) => layerIndex
|
|
).map((layerIndex) => (
|
|
<option key={layerIndex} value={layerIndex}>
|
|
{getTerrainLayerLabel(layerIndex)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<div className="material-summary">
|
|
{getTerrainLayerLabel(resolvedTerrainPaintLayerIndex)}{" "}
|
|
uses{" "}
|
|
{selectedTerrainActivePaintMaterial?.name ??
|
|
selectedTerrainActivePaintLayer?.materialId ??
|
|
"no assigned material"}
|
|
.
|
|
</div>
|
|
<div className="terrain-layer-list">
|
|
{selectedTerrain.layers.map((layer, layerIndex) => (
|
|
<label key={layerIndex} className="form-field">
|
|
<span className="label">
|
|
{getTerrainLayerLabel(layerIndex)}
|
|
</span>
|
|
<select
|
|
data-testid={`terrain-layer-material-${layerIndex}`}
|
|
value={layer.materialId ?? ""}
|
|
onChange={(event) =>
|
|
handleTerrainLayerMaterialChange(
|
|
layerIndex,
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
>
|
|
<option value="">Unassigned</option>
|
|
{materialList.map((material) => (
|
|
<option key={material.id} value={material.id}>
|
|
{material.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Brush Settings</div>
|
|
<label className="form-field">
|
|
<span className="label">
|
|
Radius {terrainBrushSettings.radius.toFixed(2)}m
|
|
</span>
|
|
<input
|
|
data-testid="terrain-brush-radius"
|
|
type="range"
|
|
min="0.25"
|
|
max="12"
|
|
step="0.25"
|
|
value={terrainBrushSettings.radius}
|
|
onChange={(event) =>
|
|
handleTerrainBrushRadiusChange(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">
|
|
Strength {terrainBrushSettings.strength.toFixed(2)}
|
|
</span>
|
|
<input
|
|
data-testid="terrain-brush-strength"
|
|
type="range"
|
|
min="0.05"
|
|
max="1"
|
|
step="0.05"
|
|
value={terrainBrushSettings.strength}
|
|
onChange={(event) =>
|
|
handleTerrainBrushStrengthChange(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">
|
|
Falloff {terrainBrushSettings.falloff.toFixed(2)}
|
|
</span>
|
|
<input
|
|
data-testid="terrain-brush-falloff"
|
|
type="range"
|
|
min="0"
|
|
max="1"
|
|
step="0.05"
|
|
value={terrainBrushSettings.falloff}
|
|
onChange={(event) =>
|
|
handleTerrainBrushFalloffChange(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Grid</div>
|
|
{selectedTerrainFootprint === null ? null : (
|
|
<div className="material-summary">
|
|
{selectedTerrainFootprint.width}m x{" "}
|
|
{selectedTerrainFootprint.depth}m footprint
|
|
</div>
|
|
)}
|
|
<div className="material-summary">
|
|
{selectedTerrain.heights.length} height samples
|
|
</div>
|
|
{selectedTerrainHeightRange === null ? null : (
|
|
<div className="material-summary">
|
|
Height range {selectedTerrainHeightRange.min}m to{" "}
|
|
{selectedTerrainHeightRange.max}m
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Origin</div>
|
|
<div className="material-summary">
|
|
X {selectedTerrain.position.x} · Y{" "}
|
|
{selectedTerrain.position.y} · Z{" "}
|
|
{selectedTerrain.position.z}
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : selectedModelInstance !== null ? (
|
|
<>
|
|
<div className="stat-card">
|
|
<div className="label">Model Asset</div>
|
|
<div className="value">
|
|
{selectedModelAsset?.sourceName ?? "Missing Asset"}
|
|
</div>
|
|
<div className="material-summary">
|
|
{selectedModelAssetRecord === null
|
|
? "This model instance references an asset that is missing from the registry."
|
|
: formatModelAssetSummary(selectedModelAssetRecord)}
|
|
</div>
|
|
{selectedModelAssetRecord === null ? null : (
|
|
<div className="material-summary">
|
|
{formatModelBoundingBoxLabel(selectedModelAssetRecord)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Authored State</div>
|
|
<label className="form-field form-field--toggle">
|
|
<span className="label">
|
|
Visible in editor and runner
|
|
</span>
|
|
<input
|
|
data-testid="model-instance-visible"
|
|
type="checkbox"
|
|
checked={selectedModelInstance.visible}
|
|
onChange={(event) =>
|
|
handleSetModelInstanceVisible(
|
|
selectedModelInstance,
|
|
event.currentTarget.checked
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field form-field--toggle">
|
|
<span className="label">Enabled in scene</span>
|
|
<input
|
|
data-testid="model-instance-enabled"
|
|
type="checkbox"
|
|
checked={selectedModelInstance.enabled}
|
|
onChange={(event) =>
|
|
handleSetModelInstanceEnabled(
|
|
selectedModelInstance,
|
|
event.currentTarget.checked
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<div className="material-summary">
|
|
Hidden model instances stay authored and keep their
|
|
runtime collision behavior. Disabled instances are removed
|
|
from the editor viewport, picking, and runtime build.
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Position</div>
|
|
<div className="vector-inputs">
|
|
<label className="form-field">
|
|
<span className="label">X</span>
|
|
<input
|
|
data-testid="model-instance-position-x"
|
|
className="text-input"
|
|
type="number"
|
|
step={DEFAULT_GRID_SIZE}
|
|
value={modelPositionDraft.x}
|
|
onChange={(event) => {
|
|
const nextValue = event.currentTarget.value;
|
|
setModelPositionDraft((draft) => ({
|
|
...draft,
|
|
x: nextValue
|
|
}));
|
|
}}
|
|
onBlur={applyModelInstanceChange}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyModelInstanceChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyModelInstanceChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyModelInstanceChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Y</span>
|
|
<input
|
|
data-testid="model-instance-position-y"
|
|
className="text-input"
|
|
type="number"
|
|
step={DEFAULT_GRID_SIZE}
|
|
value={modelPositionDraft.y}
|
|
onChange={(event) => {
|
|
const nextValue = event.currentTarget.value;
|
|
setModelPositionDraft((draft) => ({
|
|
...draft,
|
|
y: nextValue
|
|
}));
|
|
}}
|
|
onBlur={applyModelInstanceChange}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyModelInstanceChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyModelInstanceChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyModelInstanceChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Z</span>
|
|
<input
|
|
data-testid="model-instance-position-z"
|
|
className="text-input"
|
|
type="number"
|
|
step={DEFAULT_GRID_SIZE}
|
|
value={modelPositionDraft.z}
|
|
onChange={(event) => {
|
|
const nextValue = event.currentTarget.value;
|
|
setModelPositionDraft((draft) => ({
|
|
...draft,
|
|
z: nextValue
|
|
}));
|
|
}}
|
|
onBlur={applyModelInstanceChange}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyModelInstanceChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyModelInstanceChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyModelInstanceChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Rotation</div>
|
|
<div className="vector-inputs">
|
|
<label className="form-field">
|
|
<span className="label">X</span>
|
|
<input
|
|
data-testid="model-instance-rotation-x"
|
|
className="text-input"
|
|
type="number"
|
|
step="1"
|
|
value={modelRotationDraft.x}
|
|
onChange={(event) => {
|
|
const nextValue = event.currentTarget.value;
|
|
setModelRotationDraft((draft) => ({
|
|
...draft,
|
|
x: nextValue
|
|
}));
|
|
}}
|
|
onBlur={applyModelInstanceChange}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyModelInstanceChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyModelInstanceChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyModelInstanceChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Y</span>
|
|
<input
|
|
data-testid="model-instance-rotation-y"
|
|
className="text-input"
|
|
type="number"
|
|
step="1"
|
|
value={modelRotationDraft.y}
|
|
onChange={(event) => {
|
|
const nextValue = event.currentTarget.value;
|
|
setModelRotationDraft((draft) => ({
|
|
...draft,
|
|
y: nextValue
|
|
}));
|
|
}}
|
|
onBlur={applyModelInstanceChange}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyModelInstanceChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyModelInstanceChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyModelInstanceChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Z</span>
|
|
<input
|
|
data-testid="model-instance-rotation-z"
|
|
className="text-input"
|
|
type="number"
|
|
step="1"
|
|
value={modelRotationDraft.z}
|
|
onChange={(event) => {
|
|
const nextValue = event.currentTarget.value;
|
|
setModelRotationDraft((draft) => ({
|
|
...draft,
|
|
z: nextValue
|
|
}));
|
|
}}
|
|
onBlur={applyModelInstanceChange}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyModelInstanceChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyModelInstanceChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyModelInstanceChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Scale</div>
|
|
<div className="vector-inputs">
|
|
<label className="form-field">
|
|
<span className="label">X</span>
|
|
<input
|
|
data-testid="model-instance-scale-x"
|
|
className="text-input"
|
|
type="number"
|
|
min="0.001"
|
|
step="0.1"
|
|
value={modelScaleDraft.x}
|
|
onChange={(event) => {
|
|
const nextValue = event.currentTarget.value;
|
|
setModelScaleDraft((draft) => ({
|
|
...draft,
|
|
x: nextValue
|
|
}));
|
|
}}
|
|
onBlur={applyModelInstanceChange}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyModelInstanceChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyModelInstanceChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyModelInstanceChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Y</span>
|
|
<input
|
|
data-testid="model-instance-scale-y"
|
|
className="text-input"
|
|
type="number"
|
|
min="0.001"
|
|
step="0.1"
|
|
value={modelScaleDraft.y}
|
|
onChange={(event) => {
|
|
const nextValue = event.currentTarget.value;
|
|
setModelScaleDraft((draft) => ({
|
|
...draft,
|
|
y: nextValue
|
|
}));
|
|
}}
|
|
onBlur={applyModelInstanceChange}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyModelInstanceChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyModelInstanceChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyModelInstanceChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Z</span>
|
|
<input
|
|
data-testid="model-instance-scale-z"
|
|
className="text-input"
|
|
type="number"
|
|
min="0.001"
|
|
step="0.1"
|
|
value={modelScaleDraft.z}
|
|
onChange={(event) => {
|
|
const nextValue = event.currentTarget.value;
|
|
setModelScaleDraft((draft) => ({
|
|
...draft,
|
|
z: nextValue
|
|
}));
|
|
}}
|
|
onBlur={applyModelInstanceChange}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyModelInstanceChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyModelInstanceChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyModelInstanceChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Collision</div>
|
|
<label className="form-field">
|
|
<span className="label">Mode</span>
|
|
<select
|
|
data-testid="model-instance-collision-mode"
|
|
className="select-input"
|
|
value={selectedModelInstance.collision.mode}
|
|
onChange={(event) => {
|
|
store.executeCommand(
|
|
createUpsertModelInstanceCommand({
|
|
modelInstance: {
|
|
...selectedModelInstance,
|
|
collision: {
|
|
...selectedModelInstance.collision,
|
|
mode: event.target
|
|
.value as ModelInstanceCollisionMode
|
|
}
|
|
},
|
|
label: "Set model collision mode"
|
|
})
|
|
);
|
|
}}
|
|
>
|
|
{MODEL_INSTANCE_COLLISION_MODES.map((mode) => (
|
|
<option key={mode} value={mode}>
|
|
{mode}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="form-field">
|
|
<input
|
|
data-testid="model-instance-collision-visible"
|
|
type="checkbox"
|
|
checked={selectedModelInstance.collision.visible}
|
|
onChange={(event) => {
|
|
store.executeCommand(
|
|
createUpsertModelInstanceCommand({
|
|
modelInstance: {
|
|
...selectedModelInstance,
|
|
collision: {
|
|
...selectedModelInstance.collision,
|
|
visible: event.target.checked
|
|
}
|
|
},
|
|
label: event.target.checked
|
|
? "Show model collision debug"
|
|
: "Hide model collision debug"
|
|
})
|
|
);
|
|
}}
|
|
/>
|
|
<span className="label">
|
|
Show generated collision debug
|
|
</span>
|
|
</label>
|
|
<div className="material-summary">
|
|
{getModelInstanceCollisionModeDescription(
|
|
selectedModelInstance.collision.mode
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{selectedModelAssetRecord !== null &&
|
|
selectedModelAssetRecord.metadata.animationNames.length >
|
|
0 && (
|
|
<div className="form-section">
|
|
<div className="label">Animation</div>
|
|
<label className="form-field">
|
|
<span className="label">Clip</span>
|
|
<select
|
|
className="select-input"
|
|
value={
|
|
selectedModelInstance.animationClipName ?? ""
|
|
}
|
|
onChange={(e) => {
|
|
const clipName = e.target.value || undefined;
|
|
store.executeCommand(
|
|
createUpsertModelInstanceCommand({
|
|
modelInstance: {
|
|
...selectedModelInstance,
|
|
animationClipName: clipName
|
|
},
|
|
label: "Set animation clip"
|
|
})
|
|
);
|
|
}}
|
|
>
|
|
<option value="">— none —</option>
|
|
{selectedModelAssetRecord.metadata.animationNames.map(
|
|
(name) => (
|
|
<option key={name} value={name}>
|
|
{name}
|
|
</option>
|
|
)
|
|
)}
|
|
</select>
|
|
</label>
|
|
<label className="form-field">
|
|
<input
|
|
type="checkbox"
|
|
checked={
|
|
selectedModelInstance.animationAutoplay ?? false
|
|
}
|
|
onChange={(e) => {
|
|
store.executeCommand(
|
|
createUpsertModelInstanceCommand({
|
|
modelInstance: {
|
|
...selectedModelInstance,
|
|
animationAutoplay: e.target.checked
|
|
},
|
|
label: "Set animation autoplay"
|
|
})
|
|
);
|
|
}}
|
|
/>
|
|
<span className="label">Autoplay on scene load</span>
|
|
</label>
|
|
</div>
|
|
)}
|
|
|
|
<div className="inline-actions">
|
|
<button
|
|
className="toolbar__button"
|
|
type="button"
|
|
data-testid="apply-model-instance"
|
|
onClick={applyModelInstanceChange}
|
|
>
|
|
Apply Transform
|
|
</button>
|
|
</div>
|
|
</>
|
|
) : selectedPath !== null ? (
|
|
<>
|
|
<div className="stat-card">
|
|
<div className="label">Path</div>
|
|
<div className="value">
|
|
{getPathLabelById(selectedPath.id, pathList)}
|
|
</div>
|
|
<div className="material-summary">
|
|
{selectedPath.points.length} point
|
|
{selectedPath.points.length === 1 ? "" : "s"} ·{" "}
|
|
{selectedPath.loop ? "Looping" : "Open"}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Authored State</div>
|
|
<label className="form-field form-field--toggle">
|
|
<span className="label">Visible in editor</span>
|
|
<input
|
|
data-testid="path-visible"
|
|
type="checkbox"
|
|
checked={selectedPath.visible}
|
|
onChange={(event) =>
|
|
handleSetPathVisible(
|
|
selectedPath,
|
|
event.currentTarget.checked
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field form-field--toggle">
|
|
<span className="label">Enabled for runtime build</span>
|
|
<input
|
|
data-testid="path-enabled"
|
|
type="checkbox"
|
|
checked={selectedPath.enabled}
|
|
onChange={(event) =>
|
|
handleSetPathEnabled(
|
|
selectedPath,
|
|
event.currentTarget.checked
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<div className="material-summary">
|
|
Hidden paths stay authored but disappear from the editor
|
|
viewport. Disabled paths are omitted from the runtime path
|
|
registry.
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Name</div>
|
|
<label className="form-field">
|
|
<span className="label">Path Name</span>
|
|
<input
|
|
data-testid="path-name"
|
|
className="text-input"
|
|
type="text"
|
|
value={pathNameDraft}
|
|
placeholder="Path"
|
|
onChange={(event) =>
|
|
setPathNameDraft(event.currentTarget.value)
|
|
}
|
|
onBlur={applyPathNameChange}
|
|
onKeyDown={(event) =>
|
|
handleInlineNameInputKeyDown(event, () => {
|
|
setPathNameDraft(selectedPath.name ?? "");
|
|
})
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Loop</div>
|
|
<label className="form-field form-field--toggle">
|
|
<span className="label">
|
|
Close last point back to first
|
|
</span>
|
|
<input
|
|
data-testid="path-loop"
|
|
type="checkbox"
|
|
checked={selectedPath.loop}
|
|
onChange={(event) =>
|
|
handlePathLoopChange(event.currentTarget.checked)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Points</div>
|
|
<div className="material-summary">
|
|
Edit authored point positions directly. Keep at least{" "}
|
|
{MIN_SCENE_PATH_POINT_COUNT} points. <code>Shift+W</code>{" "}
|
|
appends a new point to the end of the selected path.
|
|
</div>
|
|
{selectedPathPointIndex === null ? null : (
|
|
<div className="material-summary">
|
|
Selected in viewport: Point {selectedPathPointIndex + 1}
|
|
</div>
|
|
)}
|
|
{selectedPath.points.map((point, pointIndex) => (
|
|
<div key={point.id} className="stat-card">
|
|
<div className="label">Point {pointIndex + 1}</div>
|
|
<div className="material-summary">
|
|
{point.id}
|
|
{isPathPointSelected(
|
|
editorState.selection,
|
|
selectedPath.id,
|
|
point.id
|
|
)
|
|
? " · Selected in viewport"
|
|
: ""}
|
|
</div>
|
|
<div className="vector-inputs">
|
|
<label className="form-field">
|
|
<span className="label">X</span>
|
|
<input
|
|
data-testid={`path-point-${pointIndex}-x`}
|
|
className="text-input"
|
|
type="number"
|
|
step={DEFAULT_GRID_SIZE}
|
|
value={pathPointDrafts[pointIndex]?.x ?? ""}
|
|
onChange={(event) =>
|
|
handlePathPointDraftChange(
|
|
pointIndex,
|
|
"x",
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={applyPathChange}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(event, applyPathChange)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(event, applyPathChange)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyPathChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Y</span>
|
|
<input
|
|
data-testid={`path-point-${pointIndex}-y`}
|
|
className="text-input"
|
|
type="number"
|
|
step={DEFAULT_GRID_SIZE}
|
|
value={pathPointDrafts[pointIndex]?.y ?? ""}
|
|
onChange={(event) =>
|
|
handlePathPointDraftChange(
|
|
pointIndex,
|
|
"y",
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={applyPathChange}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(event, applyPathChange)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(event, applyPathChange)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyPathChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Z</span>
|
|
<input
|
|
data-testid={`path-point-${pointIndex}-z`}
|
|
className="text-input"
|
|
type="number"
|
|
step={DEFAULT_GRID_SIZE}
|
|
value={pathPointDrafts[pointIndex]?.z ?? ""}
|
|
onChange={(event) =>
|
|
handlePathPointDraftChange(
|
|
pointIndex,
|
|
"z",
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={applyPathChange}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(event, applyPathChange)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(event, applyPathChange)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyPathChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
<div className="inline-actions">
|
|
<button
|
|
className="toolbar__button"
|
|
type="button"
|
|
data-testid={`remove-path-point-${pointIndex}`}
|
|
disabled={
|
|
selectedPath.points.length <=
|
|
MIN_SCENE_PATH_POINT_COUNT
|
|
}
|
|
onClick={() => handleDeletePathPoint(point.id)}
|
|
>
|
|
Remove Point
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
<div className="inline-actions">
|
|
<button
|
|
className="toolbar__button"
|
|
type="button"
|
|
data-testid="add-path-point"
|
|
onClick={handleAddPathPoint}
|
|
>
|
|
Add Point
|
|
</button>
|
|
<button
|
|
className="toolbar__button"
|
|
type="button"
|
|
data-testid="apply-path-points"
|
|
onClick={applyPathChange}
|
|
>
|
|
Apply Points
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : selectedEntity !== null ? (
|
|
<>
|
|
<div className="stat-card">
|
|
<div className="label">Entity Kind</div>
|
|
<div className="value">
|
|
{getEntityKindLabel(selectedEntity.kind)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Authored State</div>
|
|
<label className="form-field form-field--toggle">
|
|
<span className="label">
|
|
Visible in editor and runner
|
|
</span>
|
|
<input
|
|
data-testid="entity-visible"
|
|
type="checkbox"
|
|
checked={selectedEntity.visible}
|
|
onChange={(event) =>
|
|
handleSetEntityVisible(
|
|
selectedEntity,
|
|
event.currentTarget.checked
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field form-field--toggle">
|
|
<span className="label">Enabled in scene</span>
|
|
<input
|
|
data-testid="entity-enabled"
|
|
type="checkbox"
|
|
checked={selectedEntity.enabled}
|
|
onChange={(event) =>
|
|
handleSetEntityEnabled(
|
|
selectedEntity,
|
|
event.currentTarget.checked
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<div className="material-summary">
|
|
Hidden entities stay active for authored runtime behavior.
|
|
Disable them to remove them from the editor viewport,
|
|
picking, and runtime build.
|
|
</div>
|
|
</div>
|
|
|
|
{selectedEntity.kind !== "cameraRig" ||
|
|
selectedEntity.rigType === "fixed" ? (
|
|
<div className="form-section">
|
|
<div className="label">Position</div>
|
|
<div className="vector-inputs">
|
|
<label className="form-field">
|
|
<span className="label">X</span>
|
|
<input
|
|
data-testid={
|
|
selectedEntity.kind === "playerStart"
|
|
? "player-start-position-x"
|
|
: `${selectedEntity.kind}-position-x`
|
|
}
|
|
className="text-input"
|
|
type="number"
|
|
step={DEFAULT_GRID_SIZE}
|
|
value={entityPositionDraft.x}
|
|
onChange={(event) => {
|
|
const nextValue = event.currentTarget.value;
|
|
setEntityPositionDraft((draft) => ({
|
|
...draft,
|
|
x: nextValue
|
|
}));
|
|
}}
|
|
onBlur={applySelectedEntityDraftChange}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applySelectedEntityDraftChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applySelectedEntityDraftChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applySelectedEntityDraftChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Y</span>
|
|
<input
|
|
data-testid={
|
|
selectedEntity.kind === "playerStart"
|
|
? "player-start-position-y"
|
|
: `${selectedEntity.kind}-position-y`
|
|
}
|
|
className="text-input"
|
|
type="number"
|
|
step={DEFAULT_GRID_SIZE}
|
|
value={entityPositionDraft.y}
|
|
onChange={(event) => {
|
|
const nextValue = event.currentTarget.value;
|
|
setEntityPositionDraft((draft) => ({
|
|
...draft,
|
|
y: nextValue
|
|
}));
|
|
}}
|
|
onBlur={applySelectedEntityDraftChange}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applySelectedEntityDraftChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applySelectedEntityDraftChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applySelectedEntityDraftChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Z</span>
|
|
<input
|
|
data-testid={
|
|
selectedEntity.kind === "playerStart"
|
|
? "player-start-position-z"
|
|
: `${selectedEntity.kind}-position-z`
|
|
}
|
|
className="text-input"
|
|
type="number"
|
|
step={DEFAULT_GRID_SIZE}
|
|
value={entityPositionDraft.z}
|
|
onChange={(event) => {
|
|
const nextValue = event.currentTarget.value;
|
|
setEntityPositionDraft((draft) => ({
|
|
...draft,
|
|
z: nextValue
|
|
}));
|
|
}}
|
|
onBlur={applySelectedEntityDraftChange}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applySelectedEntityDraftChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applySelectedEntityDraftChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applySelectedEntityDraftChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{selectedPointLight !== null ? (
|
|
<>
|
|
<div className="form-section">
|
|
<div className="label">Light</div>
|
|
<div className="vector-inputs vector-inputs--two">
|
|
<label className="form-field">
|
|
<span className="label">Color</span>
|
|
<input
|
|
data-testid="point-light-color"
|
|
className="color-input"
|
|
type="color"
|
|
value={pointLightColorDraft}
|
|
onChange={(event) => {
|
|
const nextColorHex = event.currentTarget.value;
|
|
setPointLightColorDraft(nextColorHex);
|
|
scheduleDraftCommit(() =>
|
|
applyPointLightChange({
|
|
colorHex: nextColorHex
|
|
})
|
|
);
|
|
}}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Intensity</span>
|
|
<input
|
|
data-testid="point-light-intensity"
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
step="0.1"
|
|
value={pointLightIntensityDraft}
|
|
onChange={(event) =>
|
|
setPointLightIntensityDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={() => applyPointLightChange()}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyPointLightChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyPointLightChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyPointLightChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Range</div>
|
|
<label className="form-field">
|
|
<span className="label">Distance</span>
|
|
<input
|
|
data-testid="point-light-distance"
|
|
className="text-input"
|
|
type="number"
|
|
min="0.1"
|
|
step="0.1"
|
|
value={pointLightDistanceDraft}
|
|
onChange={(event) =>
|
|
setPointLightDistanceDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={() => applyPointLightChange()}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyPointLightChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyPointLightChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyPointLightChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</>
|
|
) : null}
|
|
|
|
{selectedSpotLight !== null ? (
|
|
<>
|
|
<div className="form-section">
|
|
<div className="label">Light</div>
|
|
<div className="vector-inputs vector-inputs--two">
|
|
<label className="form-field">
|
|
<span className="label">Color</span>
|
|
<input
|
|
data-testid="spot-light-color"
|
|
className="color-input"
|
|
type="color"
|
|
value={spotLightColorDraft}
|
|
onChange={(event) => {
|
|
const nextColorHex = event.currentTarget.value;
|
|
setSpotLightColorDraft(nextColorHex);
|
|
scheduleDraftCommit(() =>
|
|
applySpotLightChange({
|
|
colorHex: nextColorHex
|
|
})
|
|
);
|
|
}}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Intensity</span>
|
|
<input
|
|
data-testid="spot-light-intensity"
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
step="0.1"
|
|
value={spotLightIntensityDraft}
|
|
onChange={(event) =>
|
|
setSpotLightIntensityDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={() => applySpotLightChange()}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applySpotLightChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applySpotLightChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applySpotLightChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Range</div>
|
|
<div className="vector-inputs vector-inputs--two">
|
|
<label className="form-field">
|
|
<span className="label">Distance</span>
|
|
<input
|
|
data-testid="spot-light-distance"
|
|
className="text-input"
|
|
type="number"
|
|
min="0.1"
|
|
step="0.1"
|
|
value={spotLightDistanceDraft}
|
|
onChange={(event) =>
|
|
setSpotLightDistanceDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={() => applySpotLightChange()}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applySpotLightChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applySpotLightChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applySpotLightChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Angle</span>
|
|
<input
|
|
data-testid="spot-light-angle"
|
|
className="text-input"
|
|
type="number"
|
|
min="1"
|
|
max="179"
|
|
step="1"
|
|
value={spotLightAngleDraft}
|
|
onChange={(event) =>
|
|
setSpotLightAngleDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={() => applySpotLightChange()}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applySpotLightChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applySpotLightChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applySpotLightChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Direction</div>
|
|
<div className="vector-inputs">
|
|
<label className="form-field">
|
|
<span className="label">X</span>
|
|
<input
|
|
data-testid="spot-light-direction-x"
|
|
className="text-input"
|
|
type="number"
|
|
step="0.1"
|
|
value={spotLightDirectionDraft.x}
|
|
onChange={(event) => {
|
|
const nextValue = event.currentTarget.value;
|
|
setSpotLightDirectionDraft((draft) => ({
|
|
...draft,
|
|
x: nextValue
|
|
}));
|
|
}}
|
|
onBlur={() => applySpotLightChange()}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applySpotLightChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applySpotLightChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applySpotLightChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Y</span>
|
|
<input
|
|
data-testid="spot-light-direction-y"
|
|
className="text-input"
|
|
type="number"
|
|
step="0.1"
|
|
value={spotLightDirectionDraft.y}
|
|
onChange={(event) => {
|
|
const nextValue = event.currentTarget.value;
|
|
setSpotLightDirectionDraft((draft) => ({
|
|
...draft,
|
|
y: nextValue
|
|
}));
|
|
}}
|
|
onBlur={() => applySpotLightChange()}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applySpotLightChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applySpotLightChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applySpotLightChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Z</span>
|
|
<input
|
|
data-testid="spot-light-direction-z"
|
|
className="text-input"
|
|
type="number"
|
|
step="0.1"
|
|
value={spotLightDirectionDraft.z}
|
|
onChange={(event) => {
|
|
const nextValue = event.currentTarget.value;
|
|
setSpotLightDirectionDraft((draft) => ({
|
|
...draft,
|
|
z: nextValue
|
|
}));
|
|
}}
|
|
onBlur={() => applySpotLightChange()}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applySpotLightChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applySpotLightChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applySpotLightChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : null}
|
|
|
|
{selectedCameraRig !== null ? (
|
|
<>
|
|
<div className="form-section">
|
|
<div className="label">Rig</div>
|
|
<div className="vector-inputs vector-inputs--two">
|
|
<label className="form-field">
|
|
<span className="label">Type</span>
|
|
<select
|
|
data-testid="camera-rig-rig-type"
|
|
className="select-input"
|
|
value={cameraRigRigTypeDraft}
|
|
onChange={(event) => {
|
|
const nextRigType = event.currentTarget
|
|
.value as CameraRigType;
|
|
const nextPathId =
|
|
nextRigType === "rail"
|
|
? cameraRigPathIdDraft.trim() ||
|
|
cameraRigPathOptions[0]?.path.id ||
|
|
""
|
|
: "";
|
|
const defaultRailMapping =
|
|
getDefaultCameraRigRailMappingDraft(
|
|
nextPathId
|
|
);
|
|
setCameraRigRigTypeDraft(nextRigType);
|
|
setCameraRigPathIdDraft(nextPathId);
|
|
if (nextRigType === "rail") {
|
|
setCameraRigTrackStartPointDraft(
|
|
defaultRailMapping.trackStartPoint
|
|
);
|
|
setCameraRigTrackEndPointDraft(
|
|
defaultRailMapping.trackEndPoint
|
|
);
|
|
setCameraRigRailStartProgressDraft(
|
|
defaultRailMapping.railStartProgress
|
|
);
|
|
setCameraRigRailEndProgressDraft(
|
|
defaultRailMapping.railEndProgress
|
|
);
|
|
}
|
|
scheduleDraftCommit(() =>
|
|
applyCameraRigChange({
|
|
rigType: nextRigType,
|
|
pathId: nextPathId
|
|
})
|
|
);
|
|
}}
|
|
>
|
|
<option value="fixed">Fixed</option>
|
|
<option value="rail">Rail</option>
|
|
</select>
|
|
</label>
|
|
{cameraRigRigTypeDraft === "rail" ? (
|
|
<label className="form-field">
|
|
<span className="label">Path</span>
|
|
<select
|
|
data-testid="camera-rig-path"
|
|
className="select-input"
|
|
value={cameraRigPathIdDraft}
|
|
onChange={(event) => {
|
|
const nextPathId = event.currentTarget.value;
|
|
setCameraRigPathIdDraft(nextPathId);
|
|
scheduleDraftCommit(() =>
|
|
applyCameraRigChange({
|
|
pathId: nextPathId
|
|
})
|
|
);
|
|
}}
|
|
>
|
|
<option value="">— select path —</option>
|
|
{cameraRigPathOptions.map(({ path, label }) => (
|
|
<option key={path.id} value={path.id}>
|
|
{label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
) : null}
|
|
</div>
|
|
{cameraRigRigTypeDraft === "rail" ? (
|
|
<label className="form-field">
|
|
<span className="label">Placement</span>
|
|
<select
|
|
data-testid="camera-rig-rail-placement-mode"
|
|
className="select-input"
|
|
value={cameraRigRailPlacementModeDraft}
|
|
onChange={(event) => {
|
|
const nextMode = event.currentTarget
|
|
.value as CameraRigRailPlacementMode;
|
|
if (nextMode === "mapTargetBetweenPoints") {
|
|
const defaultRailMapping =
|
|
getDefaultCameraRigRailMappingDraft(
|
|
cameraRigPathIdDraft.trim() ||
|
|
cameraRigPathOptions[0]?.path.id ||
|
|
""
|
|
);
|
|
setCameraRigTrackStartPointDraft(
|
|
defaultRailMapping.trackStartPoint
|
|
);
|
|
setCameraRigTrackEndPointDraft(
|
|
defaultRailMapping.trackEndPoint
|
|
);
|
|
setCameraRigRailStartProgressDraft(
|
|
defaultRailMapping.railStartProgress
|
|
);
|
|
setCameraRigRailEndProgressDraft(
|
|
defaultRailMapping.railEndProgress
|
|
);
|
|
}
|
|
setCameraRigRailPlacementModeDraft(nextMode);
|
|
scheduleDraftCommit(() =>
|
|
applyCameraRigChange({
|
|
railPlacementMode: nextMode
|
|
})
|
|
);
|
|
}}
|
|
>
|
|
<option value="nearestToTarget">
|
|
Nearest To Target
|
|
</option>
|
|
<option value="mapTargetBetweenPoints">
|
|
Map Target Between Points
|
|
</option>
|
|
</select>
|
|
</label>
|
|
) : null}
|
|
{cameraRigRigTypeDraft === "rail" &&
|
|
cameraRigRailPlacementModeDraft ===
|
|
"mapTargetBetweenPoints" ? (
|
|
<>
|
|
<div className="material-summary">
|
|
Project the target between these world-space
|
|
points, then map that progress onto the rail span
|
|
below.
|
|
</div>
|
|
<div className="vector-inputs vector-inputs--two">
|
|
<label className="form-field">
|
|
<span className="label">Rail Start</span>
|
|
<input
|
|
data-testid="camera-rig-rail-start-progress"
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
max="1"
|
|
step="0.01"
|
|
value={cameraRigRailStartProgressDraft}
|
|
onChange={(event) =>
|
|
setCameraRigRailStartProgressDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={() => applyCameraRigChange()}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Rail End</span>
|
|
<input
|
|
data-testid="camera-rig-rail-end-progress"
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
max="1"
|
|
step="0.01"
|
|
value={cameraRigRailEndProgressDraft}
|
|
onChange={(event) =>
|
|
setCameraRigRailEndProgressDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={() => applyCameraRigChange()}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
<div className="form-section">
|
|
<div className="label">Track Segment</div>
|
|
<div className="vector-inputs">
|
|
<label className="form-field">
|
|
<span className="label">Start X</span>
|
|
<input
|
|
data-testid="camera-rig-track-start-x"
|
|
className="text-input"
|
|
type="number"
|
|
step={DEFAULT_GRID_SIZE}
|
|
value={cameraRigTrackStartPointDraft.x}
|
|
onChange={(event) =>
|
|
setCameraRigTrackStartPointDraft(
|
|
(draft) => ({
|
|
...draft,
|
|
x: event.currentTarget.value
|
|
})
|
|
)
|
|
}
|
|
onBlur={() => applyCameraRigChange()}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Start Y</span>
|
|
<input
|
|
data-testid="camera-rig-track-start-y"
|
|
className="text-input"
|
|
type="number"
|
|
step={DEFAULT_GRID_SIZE}
|
|
value={cameraRigTrackStartPointDraft.y}
|
|
onChange={(event) =>
|
|
setCameraRigTrackStartPointDraft(
|
|
(draft) => ({
|
|
...draft,
|
|
y: event.currentTarget.value
|
|
})
|
|
)
|
|
}
|
|
onBlur={() => applyCameraRigChange()}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Start Z</span>
|
|
<input
|
|
data-testid="camera-rig-track-start-z"
|
|
className="text-input"
|
|
type="number"
|
|
step={DEFAULT_GRID_SIZE}
|
|
value={cameraRigTrackStartPointDraft.z}
|
|
onChange={(event) =>
|
|
setCameraRigTrackStartPointDraft(
|
|
(draft) => ({
|
|
...draft,
|
|
z: event.currentTarget.value
|
|
})
|
|
)
|
|
}
|
|
onBlur={() => applyCameraRigChange()}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
<div className="vector-inputs">
|
|
<label className="form-field">
|
|
<span className="label">End X</span>
|
|
<input
|
|
data-testid="camera-rig-track-end-x"
|
|
className="text-input"
|
|
type="number"
|
|
step={DEFAULT_GRID_SIZE}
|
|
value={cameraRigTrackEndPointDraft.x}
|
|
onChange={(event) =>
|
|
setCameraRigTrackEndPointDraft(
|
|
(draft) => ({
|
|
...draft,
|
|
x: event.currentTarget.value
|
|
})
|
|
)
|
|
}
|
|
onBlur={() => applyCameraRigChange()}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">End Y</span>
|
|
<input
|
|
data-testid="camera-rig-track-end-y"
|
|
className="text-input"
|
|
type="number"
|
|
step={DEFAULT_GRID_SIZE}
|
|
value={cameraRigTrackEndPointDraft.y}
|
|
onChange={(event) =>
|
|
setCameraRigTrackEndPointDraft(
|
|
(draft) => ({
|
|
...draft,
|
|
y: event.currentTarget.value
|
|
})
|
|
)
|
|
}
|
|
onBlur={() => applyCameraRigChange()}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">End Z</span>
|
|
<input
|
|
data-testid="camera-rig-track-end-z"
|
|
className="text-input"
|
|
type="number"
|
|
step={DEFAULT_GRID_SIZE}
|
|
value={cameraRigTrackEndPointDraft.z}
|
|
onChange={(event) =>
|
|
setCameraRigTrackEndPointDraft(
|
|
(draft) => ({
|
|
...draft,
|
|
z: event.currentTarget.value
|
|
})
|
|
)
|
|
}
|
|
onBlur={() => applyCameraRigChange()}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Activation</div>
|
|
<div className="vector-inputs vector-inputs--two">
|
|
<label className="form-field">
|
|
<span className="label">Priority</span>
|
|
<input
|
|
data-testid="camera-rig-priority"
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
step="1"
|
|
value={cameraRigPriorityDraft}
|
|
onChange={(event) =>
|
|
setCameraRigPriorityDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={() => applyCameraRigChange()}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field form-field--toggle">
|
|
<span className="label">Default Active</span>
|
|
<input
|
|
data-testid="camera-rig-default-active"
|
|
type="checkbox"
|
|
checked={cameraRigDefaultActiveDraft}
|
|
onChange={(event) => {
|
|
const nextValue = event.currentTarget.checked;
|
|
setCameraRigDefaultActiveDraft(nextValue);
|
|
scheduleDraftCommit(() =>
|
|
applyCameraRigChange({
|
|
defaultActive: nextValue
|
|
})
|
|
);
|
|
}}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Target</div>
|
|
<label className="form-field">
|
|
<span className="label">Kind</span>
|
|
<select
|
|
data-testid="camera-rig-target-kind"
|
|
className="select-input"
|
|
value={cameraRigTargetKindDraft}
|
|
onChange={(event) => {
|
|
const nextKind = event.currentTarget
|
|
.value as CameraRigTargetKind;
|
|
if (nextKind === "actor") {
|
|
setCameraRigTargetActorIdDraft(
|
|
cameraRigTargetActorIdDraft.trim() ||
|
|
cameraRigActorOptions[0] ||
|
|
""
|
|
);
|
|
} else if (nextKind === "entity") {
|
|
setCameraRigTargetEntityIdDraft(
|
|
cameraRigTargetEntityIdDraft.trim() ||
|
|
cameraRigEntityTargetOptions[0]?.entity
|
|
.id ||
|
|
""
|
|
);
|
|
}
|
|
setCameraRigTargetKindDraft(nextKind);
|
|
scheduleDraftCommit(() =>
|
|
applyCameraRigChange({
|
|
targetKind: nextKind
|
|
})
|
|
);
|
|
}}
|
|
>
|
|
<option value="player">Player</option>
|
|
<option value="actor">Actor</option>
|
|
<option value="entity">Entity</option>
|
|
<option value="worldPoint">World Point</option>
|
|
</select>
|
|
</label>
|
|
{cameraRigTargetKindDraft === "actor" ? (
|
|
<label className="form-field">
|
|
<span className="label">Actor</span>
|
|
<select
|
|
data-testid="camera-rig-target-actor"
|
|
className="select-input"
|
|
value={cameraRigTargetActorIdDraft}
|
|
onChange={(event) => {
|
|
const nextActorId = event.currentTarget.value;
|
|
setCameraRigTargetActorIdDraft(nextActorId);
|
|
scheduleDraftCommit(() =>
|
|
applyCameraRigChange()
|
|
);
|
|
}}
|
|
>
|
|
<option value="">— select actor —</option>
|
|
{cameraRigActorOptions.map((actorId) => (
|
|
<option key={actorId} value={actorId}>
|
|
{actorId}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
) : null}
|
|
{cameraRigTargetKindDraft === "entity" ? (
|
|
<label className="form-field">
|
|
<span className="label">Entity</span>
|
|
<select
|
|
data-testid="camera-rig-target-entity"
|
|
className="select-input"
|
|
value={cameraRigTargetEntityIdDraft}
|
|
onChange={(event) => {
|
|
const nextEntityId = event.currentTarget.value;
|
|
setCameraRigTargetEntityIdDraft(nextEntityId);
|
|
scheduleDraftCommit(() =>
|
|
applyCameraRigChange()
|
|
);
|
|
}}
|
|
>
|
|
<option value="">— select entity —</option>
|
|
{cameraRigEntityTargetOptions.map(
|
|
({ entity, label }) => (
|
|
<option key={entity.id} value={entity.id}>
|
|
{label}
|
|
</option>
|
|
)
|
|
)}
|
|
</select>
|
|
</label>
|
|
) : null}
|
|
{cameraRigTargetKindDraft === "worldPoint" ? (
|
|
<div className="vector-inputs">
|
|
<label className="form-field">
|
|
<span className="label">World X</span>
|
|
<input
|
|
data-testid="camera-rig-target-world-x"
|
|
className="text-input"
|
|
type="number"
|
|
step={DEFAULT_GRID_SIZE}
|
|
value={cameraRigTargetWorldPointDraft.x}
|
|
onChange={(event) =>
|
|
setCameraRigTargetWorldPointDraft(
|
|
(draft) => ({
|
|
...draft,
|
|
x: event.currentTarget.value
|
|
})
|
|
)
|
|
}
|
|
onBlur={() => applyCameraRigChange()}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">World Y</span>
|
|
<input
|
|
data-testid="camera-rig-target-world-y"
|
|
className="text-input"
|
|
type="number"
|
|
step={DEFAULT_GRID_SIZE}
|
|
value={cameraRigTargetWorldPointDraft.y}
|
|
onChange={(event) =>
|
|
setCameraRigTargetWorldPointDraft(
|
|
(draft) => ({
|
|
...draft,
|
|
y: event.currentTarget.value
|
|
})
|
|
)
|
|
}
|
|
onBlur={() => applyCameraRigChange()}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">World Z</span>
|
|
<input
|
|
data-testid="camera-rig-target-world-z"
|
|
className="text-input"
|
|
type="number"
|
|
step={DEFAULT_GRID_SIZE}
|
|
value={cameraRigTargetWorldPointDraft.z}
|
|
onChange={(event) =>
|
|
setCameraRigTargetWorldPointDraft(
|
|
(draft) => ({
|
|
...draft,
|
|
z: event.currentTarget.value
|
|
})
|
|
)
|
|
}
|
|
onBlur={() => applyCameraRigChange()}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Target Offset</div>
|
|
<div className="vector-inputs">
|
|
<label className="form-field">
|
|
<span className="label">X</span>
|
|
<input
|
|
data-testid="camera-rig-target-offset-x"
|
|
className="text-input"
|
|
type="number"
|
|
step="0.1"
|
|
value={cameraRigTargetOffsetDraft.x}
|
|
onChange={(event) =>
|
|
setCameraRigTargetOffsetDraft((draft) => ({
|
|
...draft,
|
|
x: event.currentTarget.value
|
|
}))
|
|
}
|
|
onBlur={() => applyCameraRigChange()}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Y</span>
|
|
<input
|
|
data-testid="camera-rig-target-offset-y"
|
|
className="text-input"
|
|
type="number"
|
|
step="0.1"
|
|
value={cameraRigTargetOffsetDraft.y}
|
|
onChange={(event) =>
|
|
setCameraRigTargetOffsetDraft((draft) => ({
|
|
...draft,
|
|
y: event.currentTarget.value
|
|
}))
|
|
}
|
|
onBlur={() => applyCameraRigChange()}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Z</span>
|
|
<input
|
|
data-testid="camera-rig-target-offset-z"
|
|
className="text-input"
|
|
type="number"
|
|
step="0.1"
|
|
value={cameraRigTargetOffsetDraft.z}
|
|
onChange={(event) =>
|
|
setCameraRigTargetOffsetDraft((draft) => ({
|
|
...draft,
|
|
z: event.currentTarget.value
|
|
}))
|
|
}
|
|
onBlur={() => applyCameraRigChange()}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Transition</div>
|
|
<div className="vector-inputs vector-inputs--two">
|
|
<label className="form-field">
|
|
<span className="label">Mode</span>
|
|
<select
|
|
data-testid="camera-rig-transition-mode"
|
|
className="select-input"
|
|
value={cameraRigTransitionModeDraft}
|
|
onChange={(event) => {
|
|
const nextMode = event.currentTarget
|
|
.value as CameraRigTransitionMode;
|
|
setCameraRigTransitionModeDraft(nextMode);
|
|
scheduleDraftCommit(() =>
|
|
applyCameraRigChange({
|
|
transitionMode: nextMode
|
|
})
|
|
);
|
|
}}
|
|
>
|
|
<option value="blend">Blend</option>
|
|
<option value="cut">Cut</option>
|
|
</select>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Duration</span>
|
|
<input
|
|
data-testid="camera-rig-transition-duration"
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
step="0.05"
|
|
value={cameraRigTransitionDurationDraft}
|
|
onChange={(event) =>
|
|
setCameraRigTransitionDurationDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={() => applyCameraRigChange()}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Look Around</div>
|
|
<label className="form-field form-field--toggle">
|
|
<span className="label">Enabled</span>
|
|
<input
|
|
data-testid="camera-rig-look-around-enabled"
|
|
type="checkbox"
|
|
checked={cameraRigLookAroundEnabledDraft}
|
|
onChange={(event) => {
|
|
const nextValue = event.currentTarget.checked;
|
|
setCameraRigLookAroundEnabledDraft(nextValue);
|
|
scheduleDraftCommit(() =>
|
|
applyCameraRigChange({
|
|
lookAroundEnabled: nextValue
|
|
})
|
|
);
|
|
}}
|
|
/>
|
|
</label>
|
|
<div className="vector-inputs vector-inputs--two">
|
|
<label className="form-field">
|
|
<span className="label">Yaw Limit</span>
|
|
<input
|
|
data-testid="camera-rig-look-around-yaw-limit"
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
step="1"
|
|
value={cameraRigLookAroundYawLimitDraft}
|
|
onChange={(event) =>
|
|
setCameraRigLookAroundYawLimitDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={() => applyCameraRigChange()}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Pitch Limit</span>
|
|
<input
|
|
data-testid="camera-rig-look-around-pitch-limit"
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
step="1"
|
|
value={cameraRigLookAroundPitchLimitDraft}
|
|
onChange={(event) =>
|
|
setCameraRigLookAroundPitchLimitDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={() => applyCameraRigChange()}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
<label className="form-field">
|
|
<span className="label">Recenter Speed</span>
|
|
<input
|
|
data-testid="camera-rig-look-around-recenter-speed"
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
step="0.1"
|
|
value={cameraRigLookAroundRecenterSpeedDraft}
|
|
onChange={(event) =>
|
|
setCameraRigLookAroundRecenterSpeedDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={() => applyCameraRigChange()}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyCameraRigChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</>
|
|
) : null}
|
|
|
|
{selectedPlayerStart !== null ? (
|
|
<>
|
|
<div className="form-section">
|
|
<div className="label">Yaw</div>
|
|
<label className="form-field">
|
|
<span className="label">Degrees</span>
|
|
<input
|
|
data-testid="player-start-yaw"
|
|
className="text-input"
|
|
type="number"
|
|
step="1"
|
|
value={playerStartYawDraft}
|
|
onChange={(event) =>
|
|
setPlayerStartYawDraft(event.currentTarget.value)
|
|
}
|
|
onBlur={() => applyPlayerStartChange()}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyPlayerStartChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyPlayerStartChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyPlayerStartChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Navigation</div>
|
|
<label className="form-field">
|
|
<span className="label">Mode</span>
|
|
<select
|
|
data-testid="player-start-navigation-mode"
|
|
className="select-input"
|
|
value={playerStartNavigationModeDraft}
|
|
onChange={(event) => {
|
|
const nextMode = event.currentTarget
|
|
.value as PlayerStartNavigationMode;
|
|
setPlayerStartNavigationModeDraft(nextMode);
|
|
scheduleDraftCommit(() =>
|
|
applyPlayerStartChange({
|
|
navigationMode: nextMode
|
|
})
|
|
);
|
|
}}
|
|
>
|
|
{PLAYER_START_NAVIGATION_MODES.map((mode) => (
|
|
<option key={mode} value={mode}>
|
|
{mode === "firstPerson"
|
|
? "First Person"
|
|
: "Third Person"}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Interaction</div>
|
|
<div className="vector-inputs vector-inputs--two">
|
|
<label className="form-field">
|
|
<span className="label">Reach (m)</span>
|
|
<input
|
|
data-testid="player-start-interaction-reach"
|
|
className="text-input"
|
|
type="number"
|
|
min="0.1"
|
|
step="0.1"
|
|
value={playerStartInteractionReachDraft}
|
|
onChange={(event) =>
|
|
setPlayerStartInteractionReachDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={() => applyPlayerStartChange()}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyPlayerStartChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyPlayerStartChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyPlayerStartChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Angle (deg)</span>
|
|
<input
|
|
data-testid="player-start-interaction-angle"
|
|
className="text-input"
|
|
type="number"
|
|
min="1"
|
|
max="179"
|
|
step="1"
|
|
value={playerStartInteractionAngleDraft}
|
|
onChange={(event) =>
|
|
setPlayerStartInteractionAngleDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={() => applyPlayerStartChange()}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyPlayerStartChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyPlayerStartChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyPlayerStartChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Movement Template</div>
|
|
<label className="form-field">
|
|
<span className="label">Template</span>
|
|
<select
|
|
data-testid="player-start-movement-template"
|
|
className="select-input"
|
|
value={playerStartMovementTemplateDraft.kind}
|
|
onChange={(event) => {
|
|
const nextKind = event.currentTarget
|
|
.value as PlayerStartMovementTemplate["kind"];
|
|
const nextTemplate =
|
|
nextKind === "custom"
|
|
? createPlayerStartMovementTemplate({
|
|
kind: "custom",
|
|
moveSpeed:
|
|
playerStartMovementTemplateDraft.moveSpeed,
|
|
maxSpeed:
|
|
playerStartMovementTemplateDraft.maxSpeed,
|
|
maxStepHeight:
|
|
playerStartMovementTemplateDraft.maxStepHeight,
|
|
capabilities:
|
|
playerStartMovementTemplateDraft.capabilities,
|
|
jump: playerStartMovementTemplateDraft.jump,
|
|
sprint:
|
|
playerStartMovementTemplateDraft.sprint,
|
|
crouch:
|
|
playerStartMovementTemplateDraft.crouch
|
|
})
|
|
: createPlayerStartMovementTemplate({
|
|
kind: nextKind
|
|
});
|
|
setPlayerStartMovementTemplateEditorDraft(
|
|
nextTemplate
|
|
);
|
|
scheduleDraftCommit(() =>
|
|
applyPlayerStartChange({
|
|
movementTemplate: nextTemplate
|
|
})
|
|
);
|
|
}}
|
|
>
|
|
{PLAYER_START_MOVEMENT_TEMPLATE_KINDS.map(
|
|
(templateKind) => (
|
|
<option key={templateKind} value={templateKind}>
|
|
{getPlayerStartMovementTemplateLabel(
|
|
templateKind
|
|
)}
|
|
</option>
|
|
)
|
|
)}
|
|
</select>
|
|
</label>
|
|
<div className="material-summary">
|
|
{getPlayerStartMovementTemplateDescription(
|
|
playerStartMovementTemplateDraft.kind
|
|
)}
|
|
</div>
|
|
|
|
<div className="vector-inputs vector-inputs--two">
|
|
<label className="form-field">
|
|
<span className="label">Base Speed</span>
|
|
<input
|
|
data-testid="player-start-movement-move-speed"
|
|
className="text-input"
|
|
type="number"
|
|
min="0.01"
|
|
step="0.1"
|
|
value={
|
|
playerStartMovementTemplateNumberDraft.moveSpeed
|
|
}
|
|
onChange={(event) => {
|
|
const nextValue = event.currentTarget.value;
|
|
setPlayerStartMovementTemplateNumberDraft(
|
|
(draft) => ({
|
|
...draft,
|
|
moveSpeed: nextValue
|
|
})
|
|
);
|
|
}}
|
|
onBlur={() =>
|
|
commitPlayerStartMovementTemplateDraft()
|
|
}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
commitPlayerStartMovementTemplateDraft
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
commitPlayerStartMovementTemplateDraft
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
commitPlayerStartMovementTemplateDraft
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Max Speed</span>
|
|
<input
|
|
data-testid="player-start-movement-max-speed"
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
step="0.1"
|
|
value={
|
|
playerStartMovementTemplateNumberDraft.maxSpeed
|
|
}
|
|
onChange={(event) => {
|
|
const nextValue = event.currentTarget.value;
|
|
setPlayerStartMovementTemplateNumberDraft(
|
|
(draft) => ({
|
|
...draft,
|
|
maxSpeed: nextValue
|
|
})
|
|
);
|
|
}}
|
|
onBlur={() =>
|
|
commitPlayerStartMovementTemplateDraft()
|
|
}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
commitPlayerStartMovementTemplateDraft
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
commitPlayerStartMovementTemplateDraft
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
commitPlayerStartMovementTemplateDraft
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
<div className="vector-inputs vector-inputs--two">
|
|
<label className="form-field">
|
|
<span className="label">Max Step Height</span>
|
|
<input
|
|
data-testid="player-start-movement-max-step-height"
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
step="0.01"
|
|
value={
|
|
playerStartMovementTemplateNumberDraft.maxStepHeight
|
|
}
|
|
onChange={(event) => {
|
|
const nextValue = event.currentTarget.value;
|
|
setPlayerStartMovementTemplateNumberDraft(
|
|
(draft) => ({
|
|
...draft,
|
|
maxStepHeight: nextValue
|
|
})
|
|
);
|
|
}}
|
|
onBlur={() =>
|
|
commitPlayerStartMovementTemplateDraft()
|
|
}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
commitPlayerStartMovementTemplateDraft
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
commitPlayerStartMovementTemplateDraft
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
commitPlayerStartMovementTemplateDraft
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Jump</div>
|
|
<label className="form-field form-field--toggle">
|
|
<span className="label">Enabled</span>
|
|
<input
|
|
data-testid="player-start-movement-jump-enabled"
|
|
type="checkbox"
|
|
checked={
|
|
playerStartMovementTemplateDraft.capabilities
|
|
.jump
|
|
}
|
|
onChange={(event) =>
|
|
commitPlayerStartMovementTemplateDraft(
|
|
{
|
|
capabilities: {
|
|
jump: event.currentTarget.checked
|
|
}
|
|
},
|
|
{
|
|
schedule: true
|
|
}
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<div className="vector-inputs vector-inputs--two">
|
|
<label className="form-field">
|
|
<span className="label">Speed</span>
|
|
<input
|
|
data-testid="player-start-movement-jump-speed"
|
|
className="text-input"
|
|
type="number"
|
|
min="0.01"
|
|
step="0.1"
|
|
value={
|
|
playerStartMovementTemplateNumberDraft.jumpSpeed
|
|
}
|
|
onChange={(event) => {
|
|
const nextValue = event.currentTarget.value;
|
|
setPlayerStartMovementTemplateNumberDraft(
|
|
(draft) => ({
|
|
...draft,
|
|
jumpSpeed: nextValue
|
|
})
|
|
);
|
|
}}
|
|
onBlur={() =>
|
|
commitPlayerStartMovementTemplateDraft()
|
|
}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
commitPlayerStartMovementTemplateDraft
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
commitPlayerStartMovementTemplateDraft
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
commitPlayerStartMovementTemplateDraft
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Buffer ms</span>
|
|
<input
|
|
data-testid="player-start-movement-jump-buffer"
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
step="1"
|
|
value={
|
|
playerStartMovementTemplateNumberDraft.jumpBufferMs
|
|
}
|
|
onChange={(event) => {
|
|
const nextValue = event.currentTarget.value;
|
|
setPlayerStartMovementTemplateNumberDraft(
|
|
(draft) => ({
|
|
...draft,
|
|
jumpBufferMs: nextValue
|
|
})
|
|
);
|
|
}}
|
|
onBlur={() =>
|
|
commitPlayerStartMovementTemplateDraft()
|
|
}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
commitPlayerStartMovementTemplateDraft
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
commitPlayerStartMovementTemplateDraft
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
commitPlayerStartMovementTemplateDraft
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
<div className="vector-inputs vector-inputs--two">
|
|
<label className="form-field">
|
|
<span className="label">Coyote ms</span>
|
|
<input
|
|
data-testid="player-start-movement-coyote-time"
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
step="1"
|
|
value={
|
|
playerStartMovementTemplateNumberDraft.coyoteTimeMs
|
|
}
|
|
onChange={(event) => {
|
|
const nextValue = event.currentTarget.value;
|
|
setPlayerStartMovementTemplateNumberDraft(
|
|
(draft) => ({
|
|
...draft,
|
|
coyoteTimeMs: nextValue
|
|
})
|
|
);
|
|
}}
|
|
onBlur={() =>
|
|
commitPlayerStartMovementTemplateDraft()
|
|
}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
commitPlayerStartMovementTemplateDraft
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
commitPlayerStartMovementTemplateDraft
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
commitPlayerStartMovementTemplateDraft
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Max Hold ms</span>
|
|
<input
|
|
data-testid="player-start-movement-variable-jump-max-hold"
|
|
className="text-input"
|
|
type="number"
|
|
min="0.01"
|
|
step="1"
|
|
value={
|
|
playerStartMovementTemplateNumberDraft.variableJumpMaxHoldMs
|
|
}
|
|
onChange={(event) => {
|
|
const nextValue = event.currentTarget.value;
|
|
setPlayerStartMovementTemplateNumberDraft(
|
|
(draft) => ({
|
|
...draft,
|
|
variableJumpMaxHoldMs: nextValue
|
|
})
|
|
);
|
|
}}
|
|
onBlur={() =>
|
|
commitPlayerStartMovementTemplateDraft()
|
|
}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
commitPlayerStartMovementTemplateDraft
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
commitPlayerStartMovementTemplateDraft
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
commitPlayerStartMovementTemplateDraft
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
<label className="form-field form-field--toggle">
|
|
<span className="label">Variable Height</span>
|
|
<input
|
|
data-testid="player-start-movement-variable-jump-enabled"
|
|
type="checkbox"
|
|
checked={
|
|
playerStartMovementTemplateDraft.jump
|
|
.variableHeight
|
|
}
|
|
onChange={(event) =>
|
|
commitPlayerStartMovementTemplateDraft(
|
|
{
|
|
jump: {
|
|
variableHeight:
|
|
event.currentTarget.checked
|
|
}
|
|
},
|
|
{
|
|
schedule: true
|
|
}
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field form-field--toggle">
|
|
<span className="label">Air Move: Jump</span>
|
|
<input
|
|
data-testid="player-start-movement-air-move-jump-enabled"
|
|
type="checkbox"
|
|
checked={
|
|
playerStartMovementTemplateDraft.jump
|
|
.moveWhileJumping
|
|
}
|
|
onChange={(event) =>
|
|
commitPlayerStartMovementTemplateDraft(
|
|
{
|
|
jump: {
|
|
moveWhileJumping:
|
|
event.currentTarget.checked
|
|
}
|
|
},
|
|
{
|
|
schedule: true
|
|
}
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field form-field--toggle">
|
|
<span className="label">Air Move: Fall</span>
|
|
<input
|
|
data-testid="player-start-movement-air-move-fall-enabled"
|
|
type="checkbox"
|
|
checked={
|
|
playerStartMovementTemplateDraft.jump
|
|
.moveWhileFalling
|
|
}
|
|
onChange={(event) =>
|
|
commitPlayerStartMovementTemplateDraft(
|
|
{
|
|
jump: {
|
|
moveWhileFalling:
|
|
event.currentTarget.checked
|
|
}
|
|
},
|
|
{
|
|
schedule: true
|
|
}
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field form-field--toggle">
|
|
<span className="label">Air Direction Only</span>
|
|
<input
|
|
data-testid="player-start-movement-air-direction-only-enabled"
|
|
type="checkbox"
|
|
checked={
|
|
playerStartMovementTemplateDraft.jump
|
|
.directionOnly
|
|
}
|
|
onChange={(event) =>
|
|
commitPlayerStartMovementTemplateDraft(
|
|
{
|
|
jump: {
|
|
directionOnly: event.currentTarget.checked
|
|
}
|
|
},
|
|
{
|
|
schedule: true
|
|
}
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field form-field--toggle">
|
|
<span className="label">Bunny Hopping</span>
|
|
<input
|
|
data-testid="player-start-movement-bunny-hop-enabled"
|
|
type="checkbox"
|
|
checked={
|
|
playerStartMovementTemplateDraft.jump.bunnyHop
|
|
}
|
|
onChange={(event) =>
|
|
commitPlayerStartMovementTemplateDraft(
|
|
{
|
|
jump: {
|
|
bunnyHop: event.currentTarget.checked
|
|
}
|
|
},
|
|
{
|
|
schedule: true
|
|
}
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Bunny Hop Boost</span>
|
|
<input
|
|
data-testid="player-start-movement-bunny-hop-boost"
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
step="0.01"
|
|
value={
|
|
playerStartMovementTemplateNumberDraft.bunnyHopBoost
|
|
}
|
|
onChange={(event) => {
|
|
const nextValue = event.currentTarget.value;
|
|
setPlayerStartMovementTemplateNumberDraft(
|
|
(draft) => ({
|
|
...draft,
|
|
bunnyHopBoost: nextValue
|
|
})
|
|
);
|
|
}}
|
|
onBlur={() =>
|
|
commitPlayerStartMovementTemplateDraft()
|
|
}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
commitPlayerStartMovementTemplateDraft
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
commitPlayerStartMovementTemplateDraft
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
commitPlayerStartMovementTemplateDraft
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Sprint</div>
|
|
<label className="form-field form-field--toggle">
|
|
<span className="label">Enabled</span>
|
|
<input
|
|
data-testid="player-start-movement-sprint-enabled"
|
|
type="checkbox"
|
|
checked={
|
|
playerStartMovementTemplateDraft.capabilities
|
|
.sprint
|
|
}
|
|
onChange={(event) =>
|
|
commitPlayerStartMovementTemplateDraft(
|
|
{
|
|
capabilities: {
|
|
sprint: event.currentTarget.checked
|
|
}
|
|
},
|
|
{
|
|
schedule: true
|
|
}
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Speed Multiplier</span>
|
|
<input
|
|
data-testid="player-start-movement-sprint-multiplier"
|
|
className="text-input"
|
|
type="number"
|
|
min="0.01"
|
|
step="0.05"
|
|
value={
|
|
playerStartMovementTemplateNumberDraft.sprintSpeedMultiplier
|
|
}
|
|
onChange={(event) => {
|
|
const nextValue = event.currentTarget.value;
|
|
setPlayerStartMovementTemplateNumberDraft(
|
|
(draft) => ({
|
|
...draft,
|
|
sprintSpeedMultiplier: nextValue
|
|
})
|
|
);
|
|
}}
|
|
onBlur={() =>
|
|
commitPlayerStartMovementTemplateDraft()
|
|
}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
commitPlayerStartMovementTemplateDraft
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
commitPlayerStartMovementTemplateDraft
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
commitPlayerStartMovementTemplateDraft
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Crouch</div>
|
|
<label className="form-field form-field--toggle">
|
|
<span className="label">Enabled</span>
|
|
<input
|
|
data-testid="player-start-movement-crouch-enabled"
|
|
type="checkbox"
|
|
checked={
|
|
playerStartMovementTemplateDraft.capabilities
|
|
.crouch
|
|
}
|
|
onChange={(event) =>
|
|
commitPlayerStartMovementTemplateDraft(
|
|
{
|
|
capabilities: {
|
|
crouch: event.currentTarget.checked
|
|
}
|
|
},
|
|
{
|
|
schedule: true
|
|
}
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Speed Multiplier</span>
|
|
<input
|
|
data-testid="player-start-movement-crouch-multiplier"
|
|
className="text-input"
|
|
type="number"
|
|
min="0.01"
|
|
step="0.05"
|
|
value={
|
|
playerStartMovementTemplateNumberDraft.crouchSpeedMultiplier
|
|
}
|
|
onChange={(event) => {
|
|
const nextValue = event.currentTarget.value;
|
|
setPlayerStartMovementTemplateNumberDraft(
|
|
(draft) => ({
|
|
...draft,
|
|
crouchSpeedMultiplier: nextValue
|
|
})
|
|
);
|
|
}}
|
|
onBlur={() =>
|
|
commitPlayerStartMovementTemplateDraft()
|
|
}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
commitPlayerStartMovementTemplateDraft
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
commitPlayerStartMovementTemplateDraft
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
commitPlayerStartMovementTemplateDraft
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Player Collider</div>
|
|
<label className="form-field">
|
|
<span className="label">Mode</span>
|
|
<select
|
|
data-testid="player-start-collider-mode"
|
|
className="select-input"
|
|
value={playerStartColliderModeDraft}
|
|
onChange={(event) => {
|
|
const nextMode = event.currentTarget
|
|
.value as PlayerStartColliderMode;
|
|
setPlayerStartColliderModeDraft(nextMode);
|
|
scheduleDraftCommit(() =>
|
|
applyPlayerStartChange({
|
|
colliderMode: nextMode
|
|
})
|
|
);
|
|
}}
|
|
>
|
|
{PLAYER_START_COLLIDER_MODES.map((mode) => (
|
|
<option key={mode} value={mode}>
|
|
{mode}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Eye Height</span>
|
|
<input
|
|
data-testid="player-start-eye-height"
|
|
className="text-input"
|
|
type="number"
|
|
min="0.01"
|
|
step="0.1"
|
|
value={playerStartEyeHeightDraft}
|
|
onChange={(event) =>
|
|
setPlayerStartEyeHeightDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={() => applyPlayerStartChange()}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyPlayerStartChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyPlayerStartChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyPlayerStartChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
|
|
{playerStartColliderModeDraft === "capsule" ? (
|
|
<div className="vector-inputs">
|
|
<label className="form-field">
|
|
<span className="label">Radius</span>
|
|
<input
|
|
data-testid="player-start-capsule-radius"
|
|
className="text-input"
|
|
type="number"
|
|
min="0.01"
|
|
step="0.1"
|
|
value={playerStartCapsuleRadiusDraft}
|
|
onChange={(event) =>
|
|
setPlayerStartCapsuleRadiusDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={() => applyPlayerStartChange()}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyPlayerStartChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyPlayerStartChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyPlayerStartChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Height</span>
|
|
<input
|
|
data-testid="player-start-capsule-height"
|
|
className="text-input"
|
|
type="number"
|
|
min="0.01"
|
|
step="0.1"
|
|
value={playerStartCapsuleHeightDraft}
|
|
onChange={(event) =>
|
|
setPlayerStartCapsuleHeightDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={() => applyPlayerStartChange()}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyPlayerStartChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyPlayerStartChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyPlayerStartChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
) : null}
|
|
|
|
{playerStartColliderModeDraft === "box" ? (
|
|
<div className="vector-inputs">
|
|
<label className="form-field">
|
|
<span className="label">Size X</span>
|
|
<input
|
|
data-testid="player-start-box-size-x"
|
|
className="text-input"
|
|
type="number"
|
|
min="0.01"
|
|
step="0.1"
|
|
value={playerStartBoxSizeDraft.x}
|
|
onChange={(event) => {
|
|
const nextValue = event.currentTarget.value;
|
|
setPlayerStartBoxSizeDraft((draft) => ({
|
|
...draft,
|
|
x: nextValue
|
|
}));
|
|
}}
|
|
onBlur={() => applyPlayerStartChange()}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyPlayerStartChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyPlayerStartChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyPlayerStartChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Size Y</span>
|
|
<input
|
|
data-testid="player-start-box-size-y"
|
|
className="text-input"
|
|
type="number"
|
|
min="0.01"
|
|
step="0.1"
|
|
value={playerStartBoxSizeDraft.y}
|
|
onChange={(event) => {
|
|
const nextValue = event.currentTarget.value;
|
|
setPlayerStartBoxSizeDraft((draft) => ({
|
|
...draft,
|
|
y: nextValue
|
|
}));
|
|
}}
|
|
onBlur={() => applyPlayerStartChange()}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyPlayerStartChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyPlayerStartChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyPlayerStartChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Size Z</span>
|
|
<input
|
|
data-testid="player-start-box-size-z"
|
|
className="text-input"
|
|
type="number"
|
|
min="0.01"
|
|
step="0.1"
|
|
value={playerStartBoxSizeDraft.z}
|
|
onChange={(event) => {
|
|
const nextValue = event.currentTarget.value;
|
|
setPlayerStartBoxSizeDraft((draft) => ({
|
|
...draft,
|
|
z: nextValue
|
|
}));
|
|
}}
|
|
onBlur={() => applyPlayerStartChange()}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyPlayerStartChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyPlayerStartChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyPlayerStartChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="material-summary">
|
|
{getPlayerStartColliderModeDescription(
|
|
playerStartColliderModeDraft
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Input Bindings</div>
|
|
{PLAYER_START_MOVEMENT_ACTIONS.map((action) => (
|
|
<div
|
|
key={action}
|
|
className="vector-inputs vector-inputs--two"
|
|
>
|
|
<label className="form-field">
|
|
<span className="label">
|
|
{getPlayerStartInputActionLabel(action)} Key /
|
|
Mouse
|
|
</span>
|
|
<button
|
|
type="button"
|
|
data-testid={`player-start-keyboard-binding-${action}`}
|
|
className="toolbar__button"
|
|
onClick={() => {
|
|
setPlayerStartKeyboardCaptureAction(action);
|
|
setStatusMessage(
|
|
`Press any key or mouse button for ${getPlayerStartInputActionLabel(action)}. Press Escape to cancel.`
|
|
);
|
|
}}
|
|
>
|
|
{playerStartKeyboardCaptureAction === action
|
|
? "Press Any Key..."
|
|
: formatPlayerStartKeyboardBindingLabel(
|
|
playerStartInputBindingsDraft.keyboard[
|
|
action
|
|
]
|
|
)}
|
|
</button>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">
|
|
{getPlayerStartInputActionLabel(action)} Pad
|
|
</span>
|
|
<select
|
|
data-testid={`player-start-gamepad-binding-${action}`}
|
|
className="select-input"
|
|
value={
|
|
playerStartInputBindingsDraft.gamepad[action]
|
|
}
|
|
onChange={(event) =>
|
|
handlePlayerStartMovementGamepadBindingChange(
|
|
action,
|
|
event.currentTarget
|
|
.value as PlayerStartGamepadBinding
|
|
)
|
|
}
|
|
>
|
|
{PLAYER_START_GAMEPAD_BINDINGS.map(
|
|
(binding) => (
|
|
<option key={binding} value={binding}>
|
|
{formatPlayerStartGamepadBindingLabel(
|
|
binding
|
|
)}
|
|
</option>
|
|
)
|
|
)}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
))}
|
|
|
|
{PLAYER_START_LOCOMOTION_ACTIONS.map((action) => (
|
|
<div
|
|
key={action}
|
|
className="vector-inputs vector-inputs--two"
|
|
>
|
|
<label className="form-field">
|
|
<span className="label">
|
|
{getPlayerStartInputActionLabel(action)} Key /
|
|
Mouse
|
|
</span>
|
|
<button
|
|
type="button"
|
|
data-testid={`player-start-keyboard-binding-${action}`}
|
|
className="toolbar__button"
|
|
onClick={() => {
|
|
setPlayerStartKeyboardCaptureAction(action);
|
|
setStatusMessage(
|
|
`Press any key or mouse button for ${getPlayerStartInputActionLabel(action)}. Press Escape to cancel.`
|
|
);
|
|
}}
|
|
>
|
|
{playerStartKeyboardCaptureAction === action
|
|
? "Press Any Key..."
|
|
: formatPlayerStartKeyboardBindingLabel(
|
|
playerStartInputBindingsDraft.keyboard[
|
|
action
|
|
]
|
|
)}
|
|
</button>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">
|
|
{getPlayerStartInputActionLabel(action)} Pad
|
|
</span>
|
|
<select
|
|
data-testid={`player-start-gamepad-binding-${action}`}
|
|
className="select-input"
|
|
value={
|
|
playerStartInputBindingsDraft.gamepad[action]
|
|
}
|
|
onChange={(event) =>
|
|
handlePlayerStartGamepadActionBindingChange(
|
|
action,
|
|
event.currentTarget
|
|
.value as PlayerStartGamepadActionBinding
|
|
)
|
|
}
|
|
>
|
|
{PLAYER_START_GAMEPAD_ACTION_BINDINGS.map(
|
|
(binding) => (
|
|
<option key={binding} value={binding}>
|
|
{formatPlayerStartGamepadActionBindingLabel(
|
|
binding
|
|
)}
|
|
</option>
|
|
)
|
|
)}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
))}
|
|
|
|
{PLAYER_START_SYSTEM_ACTIONS.map((action) => (
|
|
<div
|
|
key={action}
|
|
className="vector-inputs vector-inputs--two"
|
|
>
|
|
<label className="form-field">
|
|
<span className="label">
|
|
{getPlayerStartInputActionLabel(action)} Key /
|
|
Mouse
|
|
</span>
|
|
<button
|
|
type="button"
|
|
data-testid={`player-start-keyboard-binding-${action}`}
|
|
className="toolbar__button"
|
|
onClick={() => {
|
|
setPlayerStartKeyboardCaptureAction(action);
|
|
setStatusMessage(
|
|
`Press any key or mouse button for ${getPlayerStartInputActionLabel(action)}. Press Escape to cancel.`
|
|
);
|
|
}}
|
|
>
|
|
{playerStartKeyboardCaptureAction === action
|
|
? "Press Any Key..."
|
|
: formatPlayerStartKeyboardBindingLabel(
|
|
playerStartInputBindingsDraft.keyboard[
|
|
action
|
|
]
|
|
)}
|
|
</button>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">
|
|
{getPlayerStartInputActionLabel(action)} Pad
|
|
</span>
|
|
<select
|
|
data-testid={`player-start-gamepad-binding-${action}`}
|
|
className="select-input"
|
|
value={
|
|
playerStartInputBindingsDraft.gamepad[action]
|
|
}
|
|
onChange={(event) =>
|
|
handlePlayerStartGamepadActionBindingChange(
|
|
action,
|
|
event.currentTarget
|
|
.value as PlayerStartGamepadActionBinding
|
|
)
|
|
}
|
|
>
|
|
{PLAYER_START_GAMEPAD_ACTION_BINDINGS.map(
|
|
(binding) => (
|
|
<option key={binding} value={binding}>
|
|
{formatPlayerStartGamepadActionBindingLabel(
|
|
binding
|
|
)}
|
|
</option>
|
|
)
|
|
)}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
))}
|
|
|
|
<label className="form-field">
|
|
<span className="label">Camera Pad</span>
|
|
<select
|
|
data-testid="player-start-gamepad-camera-look-binding"
|
|
className="select-input"
|
|
value={
|
|
playerStartInputBindingsDraft.gamepad.cameraLook
|
|
}
|
|
onChange={(event) =>
|
|
handlePlayerStartGamepadCameraLookBindingChange(
|
|
event.currentTarget
|
|
.value as PlayerStartGamepadCameraLookBinding
|
|
)
|
|
}
|
|
>
|
|
{PLAYER_START_GAMEPAD_CAMERA_LOOK_BINDINGS.map(
|
|
(binding) => (
|
|
<option key={binding} value={binding}>
|
|
{formatPlayerStartGamepadCameraLookBindingLabel(
|
|
binding
|
|
)}
|
|
</option>
|
|
)
|
|
)}
|
|
</select>
|
|
</label>
|
|
|
|
<div className="material-summary">
|
|
These bindings feed the same typed movement,
|
|
locomotion, system, and camera actions in First Person
|
|
and Third Person. Mouse look stays available as
|
|
before.
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : null}
|
|
|
|
{selectedSceneEntry !== null ? (
|
|
<div className="form-section">
|
|
<div className="label">Arrival Facing</div>
|
|
<label className="form-field">
|
|
<span className="label">Yaw</span>
|
|
<input
|
|
data-testid="scene-entry-yaw"
|
|
className="text-input"
|
|
type="number"
|
|
step="1"
|
|
value={sceneEntryYawDraft}
|
|
onChange={(event) =>
|
|
setSceneEntryYawDraft(event.currentTarget.value)
|
|
}
|
|
onBlur={applySceneEntryChange}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applySceneEntryChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(event, applySceneEntryChange)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applySceneEntryChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
) : null}
|
|
|
|
{selectedNpc !== null ? (
|
|
<>
|
|
<div className="form-section">
|
|
<div className="label">Actor</div>
|
|
<label className="form-field">
|
|
<span className="label">Actor ID</span>
|
|
<input
|
|
data-testid="npc-actor-id"
|
|
className="text-input"
|
|
type="text"
|
|
value={npcActorIdDraft}
|
|
onChange={(event) =>
|
|
setNpcActorIdDraft(event.currentTarget.value)
|
|
}
|
|
onBlur={() => applyNpcChange()}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter") {
|
|
applyNpcChange();
|
|
}
|
|
}}
|
|
/>
|
|
</label>
|
|
<div className="stat-card">
|
|
<div className="value">
|
|
{selectedNpcSameSceneActorUsages.length > 0
|
|
? "Duplicate In Scene"
|
|
: selectedNpcOtherSceneActorUsages.length > 0
|
|
? "Reused Across Scenes"
|
|
: "Unique In Project"}
|
|
</div>
|
|
<div className="material-summary">
|
|
{selectedNpcSameSceneActorUsages.length > 0
|
|
? `This actor id is also used by ${selectedNpcSameSceneActorUsages.length} other NPC${selectedNpcSameSceneActorUsages.length === 1 ? "" : "s"} in this scene.`
|
|
: selectedNpcOtherSceneActorUsages.length > 0
|
|
? `This actor id is reused by ${selectedNpcOtherSceneActorUsages.length} NPC${selectedNpcOtherSceneActorUsages.length === 1 ? "" : "s"} in other scenes.`
|
|
: "This actor id is currently unique across the project."}
|
|
</div>
|
|
</div>
|
|
{selectedNpcOtherActorUsages.map((usage) => (
|
|
<div
|
|
key={`${usage.sceneId}:${usage.entityId}`}
|
|
className="material-summary"
|
|
>
|
|
{usage.sceneId === editorState.activeSceneId
|
|
? `Also in this scene: ${usage.entityName ?? usage.entityId}`
|
|
: `Also in ${usage.sceneName}: ${usage.entityName ?? usage.entityId}`}
|
|
</div>
|
|
))}
|
|
<div className="material-summary">
|
|
Actor IDs are stable authored identities for this NPC
|
|
and are intended to stay consistent across scenes.
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Sequencer</div>
|
|
<div className="material-summary">
|
|
NPC presence is now orchestrated from the project
|
|
sequencer instead of this inspector.
|
|
</div>
|
|
<button
|
|
className="toolbar__button toolbar__button--compact"
|
|
type="button"
|
|
onClick={() => {
|
|
setSchedulePaneOpen(true);
|
|
setSequencerMode("timeline");
|
|
const matchingRoutine = Object.values(
|
|
editorState.projectDocument.scheduler.routines
|
|
).find(
|
|
(routine) =>
|
|
routine.target.kind === "actor" &&
|
|
routine.target.actorId === selectedNpc.actorId
|
|
);
|
|
|
|
setSelectedScheduleRoutineId(
|
|
matchingRoutine?.id ?? null
|
|
);
|
|
}}
|
|
>
|
|
Open Sequencer
|
|
</button>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Dialogue</div>
|
|
<NpcDialoguesPanel
|
|
dialogues={selectedNpc.dialogues}
|
|
defaultDialogueId={selectedNpc.defaultDialogueId}
|
|
selectedDialogueId={selectedNpcDialogueId}
|
|
onSelectDialogue={setSelectedNpcDialogueId}
|
|
onSetDefaultDialogueId={(dialogueId) =>
|
|
updateSelectedNpcDialogues(
|
|
"Set NPC default dialogue",
|
|
"Updated NPC default dialogue.",
|
|
(dialogues) => ({
|
|
dialogues,
|
|
defaultDialogueId: dialogueId
|
|
})
|
|
)
|
|
}
|
|
onAddDialogue={handleAddNpcDialogue}
|
|
onDeleteDialogue={handleDeleteNpcDialogue}
|
|
onSetDialogueTitle={(dialogueId, title) =>
|
|
updateNpcDialogue(
|
|
dialogueId,
|
|
"Rename NPC dialogue",
|
|
"Updated NPC dialogue title.",
|
|
(dialogue) => {
|
|
dialogue.title =
|
|
title.trim() || "Untitled Dialogue";
|
|
}
|
|
)
|
|
}
|
|
onAddDialogueLine={(dialogueId) =>
|
|
updateNpcDialogue(
|
|
dialogueId,
|
|
"Add NPC dialogue line",
|
|
"Added NPC dialogue line.",
|
|
(dialogue) => {
|
|
dialogue.lines.push(
|
|
createProjectDialogueLine()
|
|
);
|
|
}
|
|
)
|
|
}
|
|
onDeleteDialogueLine={(dialogueId, lineId) =>
|
|
updateNpcDialogue(
|
|
dialogueId,
|
|
"Delete NPC dialogue line",
|
|
"Deleted NPC dialogue line.",
|
|
(dialogue) => {
|
|
dialogue.lines = dialogue.lines.filter(
|
|
(line) => line.id !== lineId
|
|
);
|
|
}
|
|
)
|
|
}
|
|
onSetDialogueLineText={(dialogueId, lineId, text) =>
|
|
updateNpcDialogueLine(
|
|
dialogueId,
|
|
lineId,
|
|
"Set NPC dialogue line text",
|
|
"Updated NPC dialogue line text.",
|
|
(line) => {
|
|
line.text = text.trim() || "...";
|
|
}
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
{renderInteractionLinksSection(
|
|
selectedNpc,
|
|
selectedNpcLinks
|
|
)}
|
|
|
|
<div className="form-section">
|
|
<div className="label">Facing</div>
|
|
<label className="form-field">
|
|
<span className="label">Yaw</span>
|
|
<input
|
|
data-testid="npc-yaw"
|
|
className="text-input"
|
|
type="number"
|
|
step="1"
|
|
value={npcYawDraft}
|
|
onChange={(event) =>
|
|
setNpcYawDraft(event.currentTarget.value)
|
|
}
|
|
onBlur={() => applyNpcChange()}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(event, applyNpcChange)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(event, applyNpcChange)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(event, applyNpcChange)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Collision</div>
|
|
<label className="form-field">
|
|
<span className="label">Mode</span>
|
|
<select
|
|
data-testid="npc-collider-mode"
|
|
className="select-input"
|
|
value={npcColliderModeDraft}
|
|
onChange={(event) => {
|
|
const nextMode = event.currentTarget
|
|
.value as PlayerStartColliderMode;
|
|
setNpcColliderModeDraft(nextMode);
|
|
scheduleDraftCommit(() =>
|
|
applyNpcChange({
|
|
colliderMode: nextMode
|
|
})
|
|
);
|
|
}}
|
|
>
|
|
{PLAYER_START_COLLIDER_MODES.map((mode) => (
|
|
<option key={mode} value={mode}>
|
|
{mode}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
|
|
<label className="form-field">
|
|
<span className="label">Eye Height</span>
|
|
<input
|
|
data-testid="npc-eye-height"
|
|
className="text-input"
|
|
type="number"
|
|
min="0.01"
|
|
step="0.1"
|
|
value={npcEyeHeightDraft}
|
|
onChange={(event) =>
|
|
setNpcEyeHeightDraft(event.currentTarget.value)
|
|
}
|
|
onBlur={() => applyNpcChange()}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(event, applyNpcChange)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(event, applyNpcChange)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(event, applyNpcChange)
|
|
}
|
|
/>
|
|
</label>
|
|
|
|
{npcColliderModeDraft === "capsule" ? (
|
|
<div className="vector-inputs">
|
|
<label className="form-field">
|
|
<span className="label">Radius</span>
|
|
<input
|
|
data-testid="npc-capsule-radius"
|
|
className="text-input"
|
|
type="number"
|
|
min="0.01"
|
|
step="0.1"
|
|
value={npcCapsuleRadiusDraft}
|
|
onChange={(event) =>
|
|
setNpcCapsuleRadiusDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={() => applyNpcChange()}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyNpcChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(event, applyNpcChange)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyNpcChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Height</span>
|
|
<input
|
|
data-testid="npc-capsule-height"
|
|
className="text-input"
|
|
type="number"
|
|
min="0.01"
|
|
step="0.1"
|
|
value={npcCapsuleHeightDraft}
|
|
onChange={(event) =>
|
|
setNpcCapsuleHeightDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={() => applyNpcChange()}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyNpcChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(event, applyNpcChange)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyNpcChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
) : null}
|
|
|
|
{npcColliderModeDraft === "box" ? (
|
|
<div className="vector-inputs">
|
|
<label className="form-field">
|
|
<span className="label">Size X</span>
|
|
<input
|
|
data-testid="npc-box-size-x"
|
|
className="text-input"
|
|
type="number"
|
|
min="0.01"
|
|
step="0.1"
|
|
value={npcBoxSizeDraft.x}
|
|
onChange={(event) => {
|
|
const nextValue = event.currentTarget.value;
|
|
setNpcBoxSizeDraft((draft) => ({
|
|
...draft,
|
|
x: nextValue
|
|
}));
|
|
}}
|
|
onBlur={() => applyNpcChange()}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyNpcChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(event, applyNpcChange)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyNpcChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Size Y</span>
|
|
<input
|
|
data-testid="npc-box-size-y"
|
|
className="text-input"
|
|
type="number"
|
|
min="0.01"
|
|
step="0.1"
|
|
value={npcBoxSizeDraft.y}
|
|
onChange={(event) => {
|
|
const nextValue = event.currentTarget.value;
|
|
setNpcBoxSizeDraft((draft) => ({
|
|
...draft,
|
|
y: nextValue
|
|
}));
|
|
}}
|
|
onBlur={() => applyNpcChange()}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyNpcChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(event, applyNpcChange)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyNpcChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Size Z</span>
|
|
<input
|
|
data-testid="npc-box-size-z"
|
|
className="text-input"
|
|
type="number"
|
|
min="0.01"
|
|
step="0.1"
|
|
value={npcBoxSizeDraft.z}
|
|
onChange={(event) => {
|
|
const nextValue = event.currentTarget.value;
|
|
setNpcBoxSizeDraft((draft) => ({
|
|
...draft,
|
|
z: nextValue
|
|
}));
|
|
}}
|
|
onBlur={() => applyNpcChange()}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyNpcChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(event, applyNpcChange)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyNpcChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="material-summary">
|
|
{getNpcColliderModeDescription(npcColliderModeDraft)}
|
|
</div>
|
|
<div className="material-summary">
|
|
When no model is assigned, the editor and runner use
|
|
this collider as the NPC preview volume.
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Model Asset</div>
|
|
<div className="stat-card">
|
|
<div className="value">
|
|
{selectedNpc.modelAssetId === null
|
|
? "Unassigned"
|
|
: (selectedNpcModelAssetRecord?.sourceName ??
|
|
"Missing Model Asset")}
|
|
</div>
|
|
<div className="material-summary">
|
|
{selectedNpc.modelAssetId === null
|
|
? "Choose a model asset to render this NPC in the editor and runner."
|
|
: selectedNpcModelAssetRecord === null
|
|
? `This NPC references ${selectedNpc.modelAssetId}, but the asset is missing or not a model.`
|
|
: formatModelAssetSummary(
|
|
selectedNpcModelAssetRecord
|
|
)}
|
|
</div>
|
|
</div>
|
|
<label className="form-field">
|
|
<span className="label">Model</span>
|
|
<select
|
|
data-testid="npc-model-asset"
|
|
className="select-input"
|
|
value={npcModelAssetIdDraft}
|
|
onChange={(event) => {
|
|
const nextModelAssetId =
|
|
event.currentTarget.value.trim();
|
|
setNpcModelAssetIdDraft(nextModelAssetId);
|
|
scheduleDraftCommit(() =>
|
|
applyNpcChange({
|
|
modelAssetId:
|
|
nextModelAssetId.length === 0
|
|
? null
|
|
: nextModelAssetId
|
|
})
|
|
);
|
|
}}
|
|
>
|
|
<option value="">— none —</option>
|
|
{modelAssetList.map((asset) => (
|
|
<option key={asset.id} value={asset.id}>
|
|
{asset.sourceName}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
</>
|
|
) : null}
|
|
|
|
{selectedSoundEmitter !== null ? (
|
|
<>
|
|
<div className="form-section">
|
|
<div className="label">Audio Asset</div>
|
|
<div className="stat-card">
|
|
<div className="value">
|
|
{selectedSoundEmitter.audioAssetId === null
|
|
? "Unassigned"
|
|
: (selectedSoundEmitterAudioAssetRecord?.sourceName ??
|
|
"Missing Audio Asset")}
|
|
</div>
|
|
<div className="material-summary">
|
|
{selectedSoundEmitter.audioAssetId === null
|
|
? "Choose an audio asset to make this emitter playable."
|
|
: selectedSoundEmitterAudioAssetRecord === null
|
|
? `This sound emitter references ${selectedSoundEmitter.audioAssetId}, but the asset is missing or not audio.`
|
|
: formatAudioAssetSummary(
|
|
selectedSoundEmitterAudioAssetRecord
|
|
)}
|
|
</div>
|
|
</div>
|
|
<label className="form-field">
|
|
<span className="label">Audio</span>
|
|
<select
|
|
data-testid="sound-emitter-audio-asset"
|
|
className="text-input"
|
|
value={soundEmitterAudioAssetIdDraft}
|
|
onChange={(event) => {
|
|
const nextAudioAssetId =
|
|
event.currentTarget.value.trim();
|
|
setSoundEmitterAudioAssetIdDraft(
|
|
nextAudioAssetId
|
|
);
|
|
scheduleDraftCommit(() =>
|
|
applySoundEmitterChange({
|
|
audioAssetId:
|
|
nextAudioAssetId.length === 0
|
|
? null
|
|
: nextAudioAssetId
|
|
})
|
|
);
|
|
}}
|
|
>
|
|
<option value="">— none —</option>
|
|
{audioAssetList.map((asset) => (
|
|
<option key={asset.id} value={asset.id}>
|
|
{asset.sourceName}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Volume</div>
|
|
<label className="form-field">
|
|
<span className="label">Amount</span>
|
|
<input
|
|
data-testid="sound-emitter-volume"
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
step="0.1"
|
|
value={soundEmitterVolumeDraft}
|
|
onChange={(event) =>
|
|
setSoundEmitterVolumeDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={() => applySoundEmitterChange()}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applySoundEmitterChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applySoundEmitterChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applySoundEmitterChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Distance</div>
|
|
<div className="vector-inputs vector-inputs--two">
|
|
<label className="form-field">
|
|
<span className="label">Ref Distance</span>
|
|
<input
|
|
data-testid="sound-emitter-ref-distance"
|
|
className="text-input"
|
|
type="number"
|
|
min="0.1"
|
|
step="0.1"
|
|
value={soundEmitterRefDistanceDraft}
|
|
onChange={(event) =>
|
|
setSoundEmitterRefDistanceDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={() => applySoundEmitterChange()}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applySoundEmitterChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applySoundEmitterChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applySoundEmitterChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Max Distance</span>
|
|
<input
|
|
data-testid="sound-emitter-max-distance"
|
|
className="text-input"
|
|
type="number"
|
|
min="0.1"
|
|
step="0.1"
|
|
value={soundEmitterMaxDistanceDraft}
|
|
onChange={(event) =>
|
|
setSoundEmitterMaxDistanceDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={() => applySoundEmitterChange()}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applySoundEmitterChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applySoundEmitterChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applySoundEmitterChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Playback</div>
|
|
<div className="vector-inputs vector-inputs--two">
|
|
<label className="form-field">
|
|
<span className="label">Autoplay</span>
|
|
<input
|
|
data-testid="sound-emitter-autoplay"
|
|
type="checkbox"
|
|
checked={soundEmitterAutoplayDraft}
|
|
onChange={(event) => {
|
|
const nextAutoplay =
|
|
event.currentTarget.checked;
|
|
setSoundEmitterAutoplayDraft(nextAutoplay);
|
|
scheduleDraftCommit(() =>
|
|
applySoundEmitterChange({
|
|
autoplay: nextAutoplay
|
|
})
|
|
);
|
|
}}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Loop</span>
|
|
<input
|
|
data-testid="sound-emitter-loop"
|
|
type="checkbox"
|
|
checked={soundEmitterLoopDraft}
|
|
onChange={(event) => {
|
|
const nextLoop = event.currentTarget.checked;
|
|
setSoundEmitterLoopDraft(nextLoop);
|
|
scheduleDraftCommit(() =>
|
|
applySoundEmitterChange({ loop: nextLoop })
|
|
);
|
|
}}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : null}
|
|
|
|
{selectedTriggerVolume !== null ? (
|
|
<>
|
|
<div className="form-section">
|
|
<div className="label">Size</div>
|
|
<div className="vector-inputs">
|
|
<label className="form-field">
|
|
<span className="label">X</span>
|
|
<input
|
|
data-testid="trigger-volume-size-x"
|
|
className="text-input"
|
|
type="number"
|
|
min={DEFAULT_GRID_SIZE}
|
|
step={DEFAULT_GRID_SIZE}
|
|
value={triggerVolumeSizeDraft.x}
|
|
onChange={(event) => {
|
|
const nextValue = event.currentTarget.value;
|
|
setTriggerVolumeSizeDraft((draft) => ({
|
|
...draft,
|
|
x: nextValue
|
|
}));
|
|
}}
|
|
onBlur={applyTriggerVolumeChange}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyTriggerVolumeChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyTriggerVolumeChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyTriggerVolumeChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Y</span>
|
|
<input
|
|
data-testid="trigger-volume-size-y"
|
|
className="text-input"
|
|
type="number"
|
|
min={DEFAULT_GRID_SIZE}
|
|
step={DEFAULT_GRID_SIZE}
|
|
value={triggerVolumeSizeDraft.y}
|
|
onChange={(event) => {
|
|
const nextValue = event.currentTarget.value;
|
|
setTriggerVolumeSizeDraft((draft) => ({
|
|
...draft,
|
|
y: nextValue
|
|
}));
|
|
}}
|
|
onBlur={applyTriggerVolumeChange}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyTriggerVolumeChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyTriggerVolumeChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyTriggerVolumeChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Z</span>
|
|
<input
|
|
data-testid="trigger-volume-size-z"
|
|
className="text-input"
|
|
type="number"
|
|
min={DEFAULT_GRID_SIZE}
|
|
step={DEFAULT_GRID_SIZE}
|
|
value={triggerVolumeSizeDraft.z}
|
|
onChange={(event) => {
|
|
const nextValue = event.currentTarget.value;
|
|
setTriggerVolumeSizeDraft((draft) => ({
|
|
...draft,
|
|
z: nextValue
|
|
}));
|
|
}}
|
|
onBlur={applyTriggerVolumeChange}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyTriggerVolumeChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyTriggerVolumeChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyTriggerVolumeChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
{renderInteractionLinksSection(
|
|
selectedTriggerVolume,
|
|
selectedTriggerVolumeLinks
|
|
)}
|
|
</>
|
|
) : null}
|
|
|
|
{selectedTeleportTarget !== null ? (
|
|
<div className="form-section">
|
|
<div className="label">Yaw</div>
|
|
<label className="form-field">
|
|
<span className="label">Degrees</span>
|
|
<input
|
|
data-testid="teleport-target-yaw"
|
|
className="text-input"
|
|
type="number"
|
|
step="1"
|
|
value={teleportTargetYawDraft}
|
|
onChange={(event) =>
|
|
setTeleportTargetYawDraft(event.currentTarget.value)
|
|
}
|
|
onBlur={applyTeleportTargetChange}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyTeleportTargetChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyTeleportTargetChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyTeleportTargetChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
) : null}
|
|
|
|
{selectedInteractable !== null ? (
|
|
<>
|
|
<div className="form-section">
|
|
<div className="label">Interaction</div>
|
|
<div className="vector-inputs vector-inputs--two">
|
|
<label className="form-field">
|
|
<span className="label">Range</span>
|
|
<input
|
|
data-testid="interactable-radius"
|
|
className="text-input"
|
|
type="number"
|
|
min="0.1"
|
|
step="0.1"
|
|
value={interactableRadiusDraft}
|
|
onChange={(event) =>
|
|
setInteractableRadiusDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={() => applyInteractableChange()}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyInteractableChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyInteractableChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyInteractableChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Interaction Enabled</span>
|
|
<input
|
|
data-testid="interactable-enabled"
|
|
type="checkbox"
|
|
checked={interactableEnabledDraft}
|
|
onChange={(event) => {
|
|
const nextEnabled = event.currentTarget.checked;
|
|
setInteractableEnabledDraft(nextEnabled);
|
|
scheduleDraftCommit(() =>
|
|
applyInteractableChange({
|
|
interactionEnabled: nextEnabled
|
|
})
|
|
);
|
|
}}
|
|
/>
|
|
</label>
|
|
</div>
|
|
<div className="material-summary">
|
|
Range defines how close the player must be before the
|
|
click prompt can activate.
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Prompt</div>
|
|
<label className="form-field">
|
|
<span className="label">Text</span>
|
|
<input
|
|
data-testid="interactable-prompt"
|
|
className="text-input"
|
|
type="text"
|
|
value={interactablePromptDraft}
|
|
onChange={(event) =>
|
|
setInteractablePromptDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={() => applyInteractableChange()}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter") {
|
|
applyInteractableChange();
|
|
}
|
|
}}
|
|
/>
|
|
</label>
|
|
</div>
|
|
|
|
{renderInteractionLinksSection(
|
|
selectedInteractable,
|
|
selectedInteractableLinks
|
|
)}
|
|
</>
|
|
) : null}
|
|
</>
|
|
) : selectedBrush === null ? (
|
|
<div className="outliner-empty">
|
|
Select a whitebox solid, model instance, or entity to edit
|
|
authored properties.
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="stat-card">
|
|
<div className="label">Whitebox Solid Type</div>
|
|
<div className="value">box</div>
|
|
</div>
|
|
|
|
<div className="stat-card">
|
|
<div className="label">Selection Mode</div>
|
|
<div className="value">
|
|
{getWhiteboxSelectionModeLabel(whiteboxSelectionMode)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Authored State</div>
|
|
<label className="form-field form-field--toggle">
|
|
<span className="label">
|
|
Visible in editor and runner
|
|
</span>
|
|
<input
|
|
data-testid="brush-visible"
|
|
type="checkbox"
|
|
checked={selectedBrush.visible}
|
|
onChange={(event) =>
|
|
handleSetBrushVisible(
|
|
selectedBrush,
|
|
event.currentTarget.checked
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field form-field--toggle">
|
|
<span className="label">Enabled in scene</span>
|
|
<input
|
|
data-testid="brush-enabled"
|
|
type="checkbox"
|
|
checked={selectedBrush.enabled}
|
|
onChange={(event) =>
|
|
handleSetBrushEnabled(
|
|
selectedBrush,
|
|
event.currentTarget.checked
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<div className="material-summary">
|
|
Hidden solids keep their collision and volume behavior.
|
|
Disable them to remove them from the editor viewport,
|
|
picking, and runtime build.
|
|
</div>
|
|
</div>
|
|
|
|
{whiteboxSelectionMode !== "object" ? (
|
|
<div className="outliner-empty">
|
|
{whiteboxSelectionMode === "face"
|
|
? "Face mode keeps whole-solid transforms out of the way. Select a face to edit its material or UV transform."
|
|
: whiteboxSelectionMode === "edge"
|
|
? "Edge mode is selection-only in this slice. Edge transforms land next."
|
|
: "Vertex mode is selection-only in this slice. Vertex transforms land next."}
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="form-section">
|
|
<div className="label">Center</div>
|
|
<div className="vector-inputs">
|
|
<label className="form-field">
|
|
<span className="label">X</span>
|
|
<input
|
|
data-testid="brush-center-x"
|
|
className="text-input"
|
|
type="number"
|
|
step={whiteboxVectorInputStep}
|
|
value={positionDraft.x}
|
|
onChange={(event) => {
|
|
const nextValue = event.currentTarget.value;
|
|
setPositionDraft((draft) => ({
|
|
...draft,
|
|
x: nextValue
|
|
}));
|
|
}}
|
|
onBlur={applyPositionChange}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyPositionChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyPositionChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyPositionChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Y</span>
|
|
<input
|
|
data-testid="brush-center-y"
|
|
className="text-input"
|
|
type="number"
|
|
step={whiteboxVectorInputStep}
|
|
value={positionDraft.y}
|
|
onChange={(event) => {
|
|
const nextValue = event.currentTarget.value;
|
|
setPositionDraft((draft) => ({
|
|
...draft,
|
|
y: nextValue
|
|
}));
|
|
}}
|
|
onBlur={applyPositionChange}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyPositionChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyPositionChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyPositionChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Z</span>
|
|
<input
|
|
data-testid="brush-center-z"
|
|
className="text-input"
|
|
type="number"
|
|
step={whiteboxVectorInputStep}
|
|
value={positionDraft.z}
|
|
onChange={(event) => {
|
|
const nextValue = event.currentTarget.value;
|
|
setPositionDraft((draft) => ({
|
|
...draft,
|
|
z: nextValue
|
|
}));
|
|
}}
|
|
onBlur={applyPositionChange}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyPositionChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyPositionChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyPositionChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Rotation</div>
|
|
<div className="vector-inputs">
|
|
<label className="form-field">
|
|
<span className="label">X</span>
|
|
<input
|
|
data-testid="brush-rotation-x"
|
|
className="text-input"
|
|
type="number"
|
|
step="0.1"
|
|
value={rotationDraft.x}
|
|
onChange={(event) => {
|
|
const nextValue = event.currentTarget.value;
|
|
setRotationDraft((draft) => ({
|
|
...draft,
|
|
x: nextValue
|
|
}));
|
|
}}
|
|
onBlur={applyRotationChange}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyRotationChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyRotationChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyRotationChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Y</span>
|
|
<input
|
|
data-testid="brush-rotation-y"
|
|
className="text-input"
|
|
type="number"
|
|
step="0.1"
|
|
value={rotationDraft.y}
|
|
onChange={(event) => {
|
|
const nextValue = event.currentTarget.value;
|
|
setRotationDraft((draft) => ({
|
|
...draft,
|
|
y: nextValue
|
|
}));
|
|
}}
|
|
onBlur={applyRotationChange}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyRotationChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyRotationChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyRotationChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Z</span>
|
|
<input
|
|
data-testid="brush-rotation-z"
|
|
className="text-input"
|
|
type="number"
|
|
step="0.1"
|
|
value={rotationDraft.z}
|
|
onChange={(event) => {
|
|
const nextValue = event.currentTarget.value;
|
|
setRotationDraft((draft) => ({
|
|
...draft,
|
|
z: nextValue
|
|
}));
|
|
}}
|
|
onBlur={applyRotationChange}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyRotationChange
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyRotationChange
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyRotationChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Size</div>
|
|
<div className="vector-inputs">
|
|
<label className="form-field">
|
|
<span className="label">X</span>
|
|
<input
|
|
data-testid="brush-size-x"
|
|
className="text-input"
|
|
type="number"
|
|
min="0.01"
|
|
step={whiteboxVectorInputStep}
|
|
value={sizeDraft.x}
|
|
onChange={(event) => {
|
|
const nextValue = event.currentTarget.value;
|
|
setSizeDraft((draft) => ({
|
|
...draft,
|
|
x: nextValue
|
|
}));
|
|
}}
|
|
onBlur={applySizeChange}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(event, applySizeChange)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(event, applySizeChange)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applySizeChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Y</span>
|
|
<input
|
|
data-testid="brush-size-y"
|
|
className="text-input"
|
|
type="number"
|
|
min="0.01"
|
|
step={whiteboxVectorInputStep}
|
|
value={sizeDraft.y}
|
|
onChange={(event) => {
|
|
const nextValue = event.currentTarget.value;
|
|
setSizeDraft((draft) => ({
|
|
...draft,
|
|
y: nextValue
|
|
}));
|
|
}}
|
|
onBlur={applySizeChange}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(event, applySizeChange)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(event, applySizeChange)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applySizeChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Z</span>
|
|
<input
|
|
data-testid="brush-size-z"
|
|
className="text-input"
|
|
type="number"
|
|
min="0.01"
|
|
step={whiteboxVectorInputStep}
|
|
value={sizeDraft.z}
|
|
onChange={(event) => {
|
|
const nextValue = event.currentTarget.value;
|
|
setSizeDraft((draft) => ({
|
|
...draft,
|
|
z: nextValue
|
|
}));
|
|
}}
|
|
onBlur={applySizeChange}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(event, applySizeChange)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(event, applySizeChange)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applySizeChange
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Volume Mode</div>
|
|
<label className="form-field">
|
|
<span className="label">Mode</span>
|
|
<select
|
|
data-testid="brush-volume-mode"
|
|
className="select-input"
|
|
value={boxVolumeModeDraft}
|
|
onChange={(event) => {
|
|
const nextMode = event.currentTarget
|
|
.value as BoxBrushVolumeMode;
|
|
setBoxVolumeModeDraft(nextMode);
|
|
applyBoxVolumeModeChange(nextMode);
|
|
}}
|
|
>
|
|
{BOX_BRUSH_VOLUME_MODES.map((mode) => (
|
|
<option key={mode} value={mode}>
|
|
{formatBoxVolumeModeLabel(mode)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
|
|
{boxVolumeModeDraft === "water" ? (
|
|
<>
|
|
<div className="vector-inputs vector-inputs--two">
|
|
<label className="form-field">
|
|
<span className="label">Color</span>
|
|
<input
|
|
data-testid="brush-water-color"
|
|
className="color-input"
|
|
type="color"
|
|
value={boxVolumeWaterColorDraft}
|
|
onChange={(event) => {
|
|
const nextColorHex =
|
|
event.currentTarget.value;
|
|
setBoxVolumeWaterColorDraft(nextColorHex);
|
|
scheduleDraftCommit(() =>
|
|
applyBoxWaterColorDraft(nextColorHex)
|
|
);
|
|
}}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Surface Opacity</span>
|
|
<input
|
|
data-testid="brush-water-surface-opacity"
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
step="0.05"
|
|
value={boxVolumeWaterSurfaceOpacityDraft}
|
|
onChange={(event) =>
|
|
setBoxVolumeWaterSurfaceOpacityDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={() => applyBoxWaterSettings()}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyBoxWaterSettings
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyBoxWaterSettings
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyBoxWaterSettings
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
<label className="form-field">
|
|
<span className="label">Wave Strength</span>
|
|
<input
|
|
data-testid="brush-water-wave-strength"
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
step="0.05"
|
|
value={boxVolumeWaterWaveStrengthDraft}
|
|
onChange={(event) =>
|
|
setBoxVolumeWaterWaveStrengthDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={() => applyBoxWaterSettings()}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyBoxWaterSettings
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyBoxWaterSettings
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyBoxWaterSettings
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Foam Contact Limit</span>
|
|
<input
|
|
data-testid="brush-water-foam-contact-limit"
|
|
className="text-input"
|
|
type="number"
|
|
min="1"
|
|
max={String(
|
|
MAX_BOX_BRUSH_WATER_FOAM_CONTACT_LIMIT
|
|
)}
|
|
step="1"
|
|
value={boxVolumeWaterFoamContactLimitDraft}
|
|
onChange={(event) =>
|
|
setBoxVolumeWaterFoamContactLimitDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={() => applyBoxWaterSettings()}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyBoxWaterSettings
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyBoxWaterSettings
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyBoxWaterSettings
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field form-field--toggle">
|
|
<span className="label">
|
|
Vertical Surface Motion
|
|
</span>
|
|
<input
|
|
data-testid="brush-water-surface-displacement-enabled"
|
|
type="checkbox"
|
|
checked={
|
|
boxVolumeWaterSurfaceDisplacementEnabledDraft
|
|
}
|
|
onChange={(event) => {
|
|
const nextSurfaceDisplacementEnabled =
|
|
event.currentTarget.checked;
|
|
setBoxVolumeWaterSurfaceDisplacementEnabledDraft(
|
|
nextSurfaceDisplacementEnabled
|
|
);
|
|
scheduleDraftCommit(() =>
|
|
applyBoxWaterSettings({
|
|
surfaceDisplacementEnabled:
|
|
nextSurfaceDisplacementEnabled
|
|
})
|
|
);
|
|
}}
|
|
/>
|
|
</label>
|
|
</>
|
|
) : null}
|
|
|
|
{boxVolumeModeDraft === "fog" ? (
|
|
<>
|
|
<div className="vector-inputs vector-inputs--two">
|
|
<label className="form-field">
|
|
<span className="label">Color</span>
|
|
<input
|
|
data-testid="brush-fog-color"
|
|
className="color-input"
|
|
type="color"
|
|
value={boxVolumeFogColorDraft}
|
|
onChange={(event) => {
|
|
const nextColorHex =
|
|
event.currentTarget.value;
|
|
setBoxVolumeFogColorDraft(nextColorHex);
|
|
scheduleDraftCommit(() =>
|
|
applyBoxFogColorDraft(nextColorHex)
|
|
);
|
|
}}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Density</span>
|
|
<input
|
|
data-testid="brush-fog-density"
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
step="0.01"
|
|
value={boxVolumeFogDensityDraft}
|
|
onChange={(event) =>
|
|
setBoxVolumeFogDensityDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={applyBoxFogSettings}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyBoxFogSettings
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyBoxFogSettings
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyBoxFogSettings
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
<label className="form-field">
|
|
<span className="label">Padding</span>
|
|
<input
|
|
data-testid="brush-fog-padding"
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
step="0.01"
|
|
value={boxVolumeFogPaddingDraft}
|
|
onChange={(event) =>
|
|
setBoxVolumeFogPaddingDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={applyBoxFogSettings}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyBoxFogSettings
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyBoxFogSettings
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyBoxFogSettings
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</>
|
|
) : null}
|
|
|
|
{boxVolumeModeDraft === "light" ? (
|
|
<>
|
|
<div className="vector-inputs vector-inputs--two">
|
|
<label className="form-field">
|
|
<span className="label">Color</span>
|
|
<input
|
|
data-testid="brush-light-color"
|
|
className="color-input"
|
|
type="color"
|
|
value={boxVolumeLightColorDraft}
|
|
onChange={(event) => {
|
|
const nextColorHex =
|
|
event.currentTarget.value;
|
|
setBoxVolumeLightColorDraft(nextColorHex);
|
|
scheduleDraftCommit(() =>
|
|
applyBoxLightColorDraft(nextColorHex)
|
|
);
|
|
}}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Intensity</span>
|
|
<input
|
|
data-testid="brush-light-intensity"
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
step="0.05"
|
|
value={boxVolumeLightIntensityDraft}
|
|
onChange={(event) =>
|
|
setBoxVolumeLightIntensityDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={() => applyBoxLightSettings()}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyBoxLightSettings
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyBoxLightSettings
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyBoxLightSettings
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
<div className="vector-inputs vector-inputs--two">
|
|
<label className="form-field">
|
|
<span className="label">Padding</span>
|
|
<input
|
|
data-testid="brush-light-padding"
|
|
className="text-input"
|
|
type="number"
|
|
min="0"
|
|
step="0.01"
|
|
value={boxVolumeLightPaddingDraft}
|
|
onChange={(event) =>
|
|
setBoxVolumeLightPaddingDraft(
|
|
event.currentTarget.value
|
|
)
|
|
}
|
|
onBlur={() => applyBoxLightSettings()}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
applyBoxLightSettings
|
|
)
|
|
}
|
|
onKeyUp={(event) =>
|
|
handleNumberInputKeyUp(
|
|
event,
|
|
applyBoxLightSettings
|
|
)
|
|
}
|
|
onPointerUp={(event) =>
|
|
handleNumberInputPointerUp(
|
|
event,
|
|
applyBoxLightSettings
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">Falloff</span>
|
|
<select
|
|
data-testid="brush-light-falloff"
|
|
className="select-input"
|
|
value={boxVolumeLightFalloffDraft}
|
|
onChange={(event) => {
|
|
const nextFalloff = event.currentTarget
|
|
.value as BoxBrushLightFalloffMode;
|
|
setBoxVolumeLightFalloffDraft(nextFalloff);
|
|
scheduleDraftCommit(() =>
|
|
applyBoxLightSettings({
|
|
falloff: nextFalloff
|
|
})
|
|
);
|
|
}}
|
|
>
|
|
{BOX_BRUSH_LIGHT_FALLOFF_MODES.map(
|
|
(falloff) => (
|
|
<option key={falloff} value={falloff}>
|
|
{formatBoxLightFalloffLabel(falloff)}
|
|
</option>
|
|
)
|
|
)}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<div className="form-section">
|
|
<div className="label">Faces</div>
|
|
<div className="face-grid">
|
|
{getBrushFaceIds(selectedBrush).map((faceId) => (
|
|
<button
|
|
key={faceId}
|
|
type="button"
|
|
data-testid={`face-button-${faceId}`}
|
|
className={`face-chip ${isBrushFaceSelected(editorState.selection, selectedBrush.id, faceId) ? "face-chip--active" : ""}`}
|
|
onClick={() => {
|
|
store.setWhiteboxSelectionMode("face");
|
|
applySelection(
|
|
{
|
|
kind: "brushFace",
|
|
brushId: selectedBrush.id,
|
|
faceId
|
|
},
|
|
"inspector"
|
|
);
|
|
}}
|
|
>
|
|
<span className="face-chip__title">
|
|
{getBrushFaceLabel(selectedBrush, faceId)}
|
|
</span>
|
|
<span className="face-chip__meta">{faceId}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{whiteboxSelectionMode === "edge" ? (
|
|
selectedEdgeId === null ? (
|
|
<div className="outliner-empty">
|
|
Select an edge in the viewport to inspect it. Edge
|
|
transforms land in the next slice.
|
|
</div>
|
|
) : (
|
|
<div className="stat-card">
|
|
<div className="label">Active Edge</div>
|
|
<div className="value">
|
|
{getBrushEdgeLabel(selectedBrush, selectedEdgeId)}
|
|
</div>
|
|
<div className="material-summary">
|
|
Edge selection is visible in the viewport. Persistent
|
|
edge transforms are still deferred.
|
|
</div>
|
|
</div>
|
|
)
|
|
) : whiteboxSelectionMode === "vertex" ? (
|
|
selectedVertexId === null ? (
|
|
<div className="outliner-empty">
|
|
Select a vertex in the viewport to inspect it. Vertex
|
|
transforms land in the next slice.
|
|
</div>
|
|
) : (
|
|
<div className="stat-card">
|
|
<div className="label">Active Vertex</div>
|
|
<div className="value">
|
|
{getBrushVertexLabel(selectedBrush, selectedVertexId)}
|
|
</div>
|
|
<div className="material-summary">
|
|
Vertex selection is visible in the viewport.
|
|
Persistent vertex transforms are still deferred.
|
|
</div>
|
|
</div>
|
|
)
|
|
) : materialInspectorScope === null ? (
|
|
<div className="outliner-empty">
|
|
{whiteboxSelectionMode === "face"
|
|
? "Select a face to edit its material and UV transform."
|
|
: "Select a whitebox to edit material and UVs across all faces."}
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="stat-card">
|
|
<div className="label">
|
|
{materialInspectorActiveLabel}
|
|
</div>
|
|
<div className="value">
|
|
{materialInspectorActiveValue}
|
|
</div>
|
|
<div
|
|
className="material-summary"
|
|
data-testid="selected-face-material-name"
|
|
>
|
|
Material: {materialInspectorMaterialSummary}
|
|
</div>
|
|
{materialInspectorScope === "brush" ? (
|
|
<div className="material-summary">
|
|
Edits here apply to all six faces.
|
|
</div>
|
|
) : null}
|
|
{materialInspectorScope === "brush" &&
|
|
selectedBrushHasMixedFaceUvs ? (
|
|
<div className="material-summary">
|
|
Mixed UV transforms will be overwritten where you
|
|
edit them here.
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">Material</div>
|
|
<div className="material-browser">
|
|
{materialList.map((material) => (
|
|
<button
|
|
key={material.id}
|
|
type="button"
|
|
data-testid={`material-button-${material.id}`}
|
|
className={`material-item ${materialInspectorMaterialId === material.id ? "material-item--active" : ""}`}
|
|
onClick={() => applyFaceMaterial(material.id)}
|
|
>
|
|
<span
|
|
className="material-item__preview"
|
|
style={getMaterialPreviewStyle(material)}
|
|
aria-hidden="true"
|
|
/>
|
|
<span className="material-item__text">
|
|
<span className="material-item__title">
|
|
{material.name}
|
|
</span>
|
|
<span className="material-item__meta">
|
|
{material.tags.join(" | ")}
|
|
</span>
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="inline-actions">
|
|
<button
|
|
className="toolbar__button"
|
|
type="button"
|
|
onClick={clearFaceMaterial}
|
|
>
|
|
Clear Material
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">UV Offset</div>
|
|
<div className="vector-inputs vector-inputs--two">
|
|
<label className="form-field">
|
|
<span className="label">U</span>
|
|
<input
|
|
data-testid="face-uv-offset-x"
|
|
className="text-input"
|
|
type="number"
|
|
step="0.125"
|
|
value={uvOffsetDraft.x}
|
|
placeholder={
|
|
materialInspectorScope === "brush" &&
|
|
uvOffsetDraft.x === ""
|
|
? "Mixed"
|
|
: undefined
|
|
}
|
|
onChange={(event) => {
|
|
const nextValue = event.currentTarget.value;
|
|
setUvOffsetDraft((draft) => ({
|
|
...draft,
|
|
x: nextValue
|
|
}));
|
|
}}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
handleApplyUvDraft
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">V</span>
|
|
<input
|
|
data-testid="face-uv-offset-y"
|
|
className="text-input"
|
|
type="number"
|
|
step="0.125"
|
|
value={uvOffsetDraft.y}
|
|
placeholder={
|
|
materialInspectorScope === "brush" &&
|
|
uvOffsetDraft.y === ""
|
|
? "Mixed"
|
|
: undefined
|
|
}
|
|
onChange={(event) => {
|
|
const nextValue = event.currentTarget.value;
|
|
setUvOffsetDraft((draft) => ({
|
|
...draft,
|
|
y: nextValue
|
|
}));
|
|
}}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
handleApplyUvDraft
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="form-section">
|
|
<div className="label">UV Scale</div>
|
|
<div className="vector-inputs vector-inputs--two">
|
|
<label className="form-field">
|
|
<span className="label">U</span>
|
|
<input
|
|
data-testid="face-uv-scale-x"
|
|
className="text-input"
|
|
type="number"
|
|
min="0.001"
|
|
step="0.125"
|
|
value={uvScaleDraft.x}
|
|
placeholder={
|
|
materialInspectorScope === "brush" &&
|
|
uvScaleDraft.x === ""
|
|
? "Mixed"
|
|
: undefined
|
|
}
|
|
onChange={(event) => {
|
|
const nextValue = event.currentTarget.value;
|
|
setUvScaleDraft((draft) => ({
|
|
...draft,
|
|
x: nextValue
|
|
}));
|
|
}}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
handleApplyUvDraft
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="form-field">
|
|
<span className="label">V</span>
|
|
<input
|
|
data-testid="face-uv-scale-y"
|
|
className="text-input"
|
|
type="number"
|
|
min="0.001"
|
|
step="0.125"
|
|
value={uvScaleDraft.y}
|
|
placeholder={
|
|
materialInspectorScope === "brush" &&
|
|
uvScaleDraft.y === ""
|
|
? "Mixed"
|
|
: undefined
|
|
}
|
|
onChange={(event) => {
|
|
const nextValue = event.currentTarget.value;
|
|
setUvScaleDraft((draft) => ({
|
|
...draft,
|
|
y: nextValue
|
|
}));
|
|
}}
|
|
onKeyDown={(event) =>
|
|
handleDraftVectorKeyDown(
|
|
event,
|
|
handleApplyUvDraft
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="inline-actions">
|
|
<button
|
|
className="toolbar__button"
|
|
type="button"
|
|
data-testid="apply-face-uv"
|
|
onClick={handleApplyUvDraft}
|
|
>
|
|
Apply UV Offset/Scale
|
|
</button>
|
|
<button
|
|
className="toolbar__button"
|
|
type="button"
|
|
onClick={handleRotateUv}
|
|
>
|
|
Rotate 90
|
|
</button>
|
|
<button
|
|
className="toolbar__button"
|
|
type="button"
|
|
onClick={() => handleFlipUv("u")}
|
|
>
|
|
Flip U
|
|
</button>
|
|
<button
|
|
className="toolbar__button"
|
|
type="button"
|
|
onClick={() => handleFlipUv("v")}
|
|
>
|
|
Flip V
|
|
</button>
|
|
<button
|
|
className="toolbar__button"
|
|
type="button"
|
|
onClick={handleFitUvToFace}
|
|
>
|
|
Fit To Face
|
|
</button>
|
|
</div>
|
|
|
|
<div className="stat-card">
|
|
<div className="label">UV Flags</div>
|
|
{materialInspectorScope === "brush" &&
|
|
selectedBrushHasMixedFaceUvs ? (
|
|
<>
|
|
<div className="value">Mixed across faces</div>
|
|
<div className="material-summary">
|
|
Rotation, flips, or numeric UV transforms differ
|
|
across this solid.
|
|
</div>
|
|
</>
|
|
) : materialInspectorUvState === null ? null : (
|
|
<>
|
|
<div className="value">
|
|
Rotation{" "}
|
|
{materialInspectorUvState.rotationQuarterTurns *
|
|
90}
|
|
°
|
|
</div>
|
|
<div className="material-summary">
|
|
U{" "}
|
|
{materialInspectorUvState.flipU
|
|
? "flipped"
|
|
: "normal"}{" "}
|
|
· V{" "}
|
|
{materialInspectorUvState.flipV
|
|
? "flipped"
|
|
: "normal"}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</Panel>
|
|
)}
|
|
</aside>
|
|
</div>
|
|
|
|
{addMenuPosition === null ? null : (
|
|
<HierarchicalMenu
|
|
title="Add"
|
|
position={addMenuPosition}
|
|
items={addMenuItems}
|
|
onClose={closeAddMenu}
|
|
/>
|
|
)}
|
|
|
|
<footer className="status-bar">
|
|
<div
|
|
className="status-bar__item status-bar__item--message"
|
|
data-testid="status-message"
|
|
title={statusMessage}
|
|
>
|
|
<span className="status-bar__strong">Status:</span> {statusMessage}
|
|
</div>
|
|
<div
|
|
className="status-bar__item"
|
|
data-testid="status-whitebox-selection-mode"
|
|
>
|
|
<span className="status-bar__strong">Whitebox:</span>{" "}
|
|
{getWhiteboxSelectionModeLabel(whiteboxSelectionMode)}
|
|
</div>
|
|
<div className="status-bar__item" data-testid="status-document">
|
|
<span className="status-bar__strong">Document:</span>{" "}
|
|
{documentStatusLabel}
|
|
</div>
|
|
<div className="status-bar__item" data-testid="status-run-preflight">
|
|
<span className="status-bar__strong">Run:</span> {runReadyLabel}
|
|
</div>
|
|
<div className="status-bar__item" data-testid="status-warnings">
|
|
<span className="status-bar__strong">Warnings:</span>{" "}
|
|
{warningDiagnostics.length}
|
|
</div>
|
|
{hoveredAssetStatusMessage === null ? null : (
|
|
<div
|
|
className="status-bar__item status-bar__item--asset"
|
|
data-testid="status-asset-hover"
|
|
title={hoveredAssetStatusMessage}
|
|
>
|
|
<span className="status-bar__strong">Asset:</span>{" "}
|
|
{hoveredAssetStatusMessage}
|
|
</div>
|
|
)}
|
|
<div
|
|
className="status-bar__item"
|
|
data-testid="status-last-command"
|
|
title={lastCommandLabel}
|
|
>
|
|
<span className="status-bar__strong">Last:</span> {lastCommandLabel}
|
|
</div>
|
|
</footer>
|
|
|
|
<input
|
|
ref={importProjectInputRef}
|
|
className="visually-hidden"
|
|
type="file"
|
|
accept={`${PROJECT_PACKAGE_FILE_EXTENSION},.zip,application/zip`}
|
|
onChange={handleLoadProjectChange}
|
|
/>
|
|
<input
|
|
ref={importModelInputRef}
|
|
className="visually-hidden"
|
|
type="file"
|
|
multiple
|
|
accept=".glb,.gltf,model/gltf-binary,model/gltf+json,application/octet-stream"
|
|
onChange={handleImportModelChange}
|
|
/>
|
|
<input
|
|
ref={importBackgroundImageInputRef}
|
|
className="visually-hidden"
|
|
type="file"
|
|
accept=".avif,.exr,.gif,.hdr,.jpg,.jpeg,.png,.svg,.webp,image/*"
|
|
onChange={handleImportBackgroundImageChange}
|
|
/>
|
|
<input
|
|
ref={importAudioInputRef}
|
|
className="visually-hidden"
|
|
type="file"
|
|
accept=".aac,.flac,.m4a,.mp3,.oga,.ogg,.wav,.webm,audio/*"
|
|
onChange={handleImportAudioChange}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|