@pascal-app/editor 1.1.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +9 -15
- package/src/components/editor/action-menu-button.tsx +1 -1
- package/src/components/editor/alignment-3d-guide-layer.tsx +1 -1
- package/src/components/editor/custom-camera-controls.tsx +11 -11
- package/src/components/editor/editor-layout-v2.tsx +1 -1
- package/src/components/editor/elevation-3d-guide-layer.tsx +1 -1
- package/src/components/editor/first-person-controls.tsx +20 -1981
- package/src/components/editor/floating-action-menu.tsx +1 -1
- package/src/components/editor/floating-building-action-menu.tsx +1 -1
- package/src/components/editor/floor-region-controls.tsx +6 -2
- package/src/components/editor/floorplan-navigation-presentation.test.ts +0 -40
- package/src/components/editor/floorplan-navigation-presentation.ts +0 -66
- package/src/components/editor/floorplan-panel.tsx +26 -22
- package/src/components/editor/floorplan-wall-typing-keys.ts +1 -1
- package/src/components/editor/follow-pascal.test.tsx +1 -2
- package/src/components/editor/follow-pascal.tsx +1 -1
- package/src/components/editor/footprint-height-handle.tsx +1 -1
- package/src/components/editor/group-actions.ts +1 -2
- package/src/components/editor/group-floating-action-menu.tsx +1 -1
- package/src/components/editor/group-move-3d.ts +1 -2
- package/src/components/editor/group-rotate-handle.tsx +1 -2
- package/src/components/editor/handles/use-handle-drag.ts +1 -2
- package/src/components/editor/index.tsx +24 -14
- package/src/components/editor/inspector-extension-actions.tsx +1 -1
- package/src/components/editor/node-arrow-handles.tsx +1 -1
- package/src/components/editor/open-wall-ends-3d-layer.tsx +1 -1
- package/src/components/editor/opening-guides-3d-layer.tsx +1 -1
- package/src/components/editor/room-controls.tsx +7 -4
- package/src/components/editor/room-handles-2d.tsx +2 -3
- package/src/components/editor/room-handles.tsx +1 -2
- package/src/components/editor/room-highlight.tsx +6 -105
- package/src/components/editor/room-labels.test.ts +8 -822
- package/src/components/editor/room-labels.tsx +40 -1671
- package/src/components/editor/room-webgpu-materials.test.ts +1 -1
- package/src/components/editor/selection-manager.tsx +5 -3
- package/src/components/editor/site-edge-labels.tsx +1 -1
- package/src/components/editor/snapshot-capture-overlay.tsx +2 -5
- package/src/components/editor/use-floorplan-background-placement.ts +2 -1
- package/src/components/editor/views/plugins-view.tsx +1 -1
- package/src/components/editor/views/view-bar.tsx +9 -9
- package/src/components/editor/wall-measurement-label.tsx +1 -1
- package/src/components/editor/wall-move-side-handles.tsx +1 -1
- package/src/components/editor/wall-pull-grip.tsx +2 -2
- package/src/components/editor-2d/floorplan-group-move.tsx +2 -3
- package/src/components/editor-2d/floorplan-measurement-tool-layer.tsx +1 -1
- package/src/components/editor-2d/floorplan-open-wall-ends-layer.tsx +1 -1
- package/src/components/editor-2d/floorplan-registry-move-overlay.tsx +1 -2
- package/src/components/editor-2d/renderers/floorplan-annotation-layout.test.ts +0 -37
- package/src/components/editor-2d/renderers/floorplan-annotation-layout.ts +0 -30
- package/src/components/editor-2d/renderers/floorplan-dimension-edit-overlay.tsx +13 -3
- package/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +1 -2
- package/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +8 -10
- package/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +25 -33
- package/src/components/systems/roof/roof-edit-system.tsx +4 -3
- package/src/components/tools/distribution/distribution-run-tool.tsx +1 -1
- package/src/components/tools/item/placement-history.test.tsx +1 -1
- package/src/components/tools/item/use-placement-coordinator.tsx +2 -3
- package/src/components/tools/paint-region/wall-paint-region-tool.tsx +6 -2
- package/src/components/tools/registry/move-registry-node-tool.tsx +1 -2
- package/src/components/tools/shared/cursor-sphere.tsx +1 -1
- package/src/components/tools/shared/draft-measurement-label.tsx +1 -1
- package/src/components/tools/shared/placement-box.tsx +1 -1
- package/src/components/tools/shared/placement-dimension-guides.tsx +1 -1
- package/src/components/tools/site/terrain-brush-cursor.tsx +1 -1
- package/src/components/ui/action-menu/camera-actions.tsx +2 -2
- package/src/components/ui/action-menu/control-modes.tsx +1 -1
- package/src/components/ui/action-menu/follow-pascal-pill.tsx +1 -1
- package/src/components/ui/action-menu/index.tsx +3 -3
- package/src/components/ui/action-menu/measurement-control.tsx +3 -3
- package/src/components/ui/action-menu/placement.ts +0 -18
- package/src/components/ui/action-menu/point-ask-button.tsx +1 -1
- package/src/components/ui/action-menu/view-toggles.tsx +5 -5
- package/src/components/ui/controls/finish-swatch.tsx +1 -1
- package/src/components/ui/controls/material-paint-panel.tsx +2 -2
- package/src/components/ui/controls/scene-material-list.tsx +2 -2
- package/src/components/ui/controls/select-control.tsx +1 -1
- package/src/components/ui/controls/terrain-sculpt-panel.tsx +1 -1
- package/src/components/ui/floating-level-selector.tsx +3 -3
- package/src/components/ui/helpers/contextual-helper-panel.tsx +1 -1
- package/src/components/ui/helpers/helper-manager.test.tsx +1 -1
- package/src/components/ui/helpers/helper-manager.tsx +1 -1
- package/src/components/ui/level-duplicate-dialog.tsx +1 -1
- package/src/components/ui/panels/collections/collections-popover.tsx +2 -2
- package/src/components/ui/panels/floor-foundation-panel.tsx +2 -2
- package/src/components/ui/panels/floor-opening-panel.tsx +4 -4
- package/src/components/ui/panels/node-display.ts +1 -1
- package/src/components/ui/panels/panel-manager.tsx +1 -1
- package/src/components/ui/panels/panel-wrapper.tsx +1 -1
- package/src/components/ui/panels/parametric-inspector.tsx +1 -1
- package/src/components/ui/panels/room-construction-rows.tsx +6 -6
- package/src/components/ui/panels/room-name-options.ts +1 -5
- package/src/components/ui/panels/room-panel.tsx +6 -8
- package/src/components/ui/primitives/color-dot.tsx +2 -2
- package/src/components/ui/primitives/opacity-control.tsx +2 -2
- package/src/components/ui/primitives/sidebar.tsx +3 -3
- package/src/components/ui/right-stack.tsx +1 -1
- package/src/components/ui/sidebar/icon-rail.tsx +1 -1
- package/src/components/ui/sidebar/panels/items-panel/function-tree-panel.tsx +1 -1
- package/src/components/ui/sidebar/panels/settings-panel/audio-settings-dialog.tsx +2 -2
- package/src/components/ui/sidebar/panels/settings-panel/construction-animation-setting.tsx +1 -1
- package/src/components/ui/sidebar/panels/settings-panel/index.tsx +2 -2
- package/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +1 -1
- package/src/components/ui/sidebar/panels/settings-panel/load-build-dialog.tsx +1 -1
- package/src/components/ui/sidebar/panels/settings-panel/print-export-button.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/index.tsx +2 -2
- package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +46 -7
- package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +1 -1
- package/src/components/ui/sidebar/panels/site-panel/tree-node-drag.tsx +14 -5
- package/src/components/ui/sidebar/panels/site-panel/unit-tree-node.tsx +2 -2
- package/src/components/ui/sidebar/panels/zone-panel/index.tsx +3 -3
- package/src/components/ui/sidebar/tab-bar.tsx +1 -1
- package/src/components/ui/wall-type/wall-type-editor-dialog.tsx +1 -1
- package/src/components/ui/wall-type/wall-type-menu.tsx +1 -1
- package/src/components/viewer-overlay.tsx +1 -2
- package/src/hooks/use-auto-frame.ts +1 -2
- package/src/hooks/use-keyboard.ts +1 -2
- package/src/hooks/use-selected-room.ts +5 -7
- package/src/i18n/floor-footprint-name.ts +1 -1
- package/src/i18n/locales/en/messages.po +180 -182
- package/src/i18n/locales/es/messages.po +180 -182
- package/src/i18n/locales/fr/messages.po +180 -182
- package/src/i18n/locales/ja/messages.po +180 -182
- package/src/i18n/locales/zh-CN/messages.po +180 -182
- package/src/i18n/locales.test.ts +1 -1
- package/src/index.tsx +109 -131
- package/src/lib/below-grade.test.tsx +4 -4
- package/src/lib/camera-facing.test.ts +1 -1
- package/src/lib/camera-facing.ts +2 -3
- package/src/lib/editor-level-display.ts +1 -1
- package/src/lib/floor-footprints.test.ts +2 -2
- package/src/lib/floor-opening-draft.ts +1 -1
- package/src/lib/floorplan/annotation-visibility.test.ts +1 -1
- package/src/lib/floorplan/annotation-visibility.ts +1 -1
- package/src/lib/floorplan/floorplan-export.test.ts +1 -1
- package/src/lib/floorplan/floorplan-export.tsx +8 -11
- package/src/lib/floorplan/floorplan-extension.ts +4 -157
- package/src/lib/floorplan/floorplan-mode.ts +2 -1
- package/src/lib/floorplan/floorplan-pdfkit-renderer.test.ts +1 -1
- package/src/lib/floorplan/floorplan-pdfkit-renderer.ts +8 -6
- package/src/lib/floorplan/floorplan-plugin-install.test.tsx +3 -4
- package/src/lib/floorplan/geometry.ts +2 -112
- package/src/lib/floorplan/index.ts +0 -9
- package/src/lib/floorplan/items.ts +2 -1
- package/src/lib/floorplan/schedules.ts +6 -3
- package/src/lib/floorplan/sheet-export.ts +7 -3
- package/src/lib/floorplan/site-plan/site-parts.ts +7 -1
- package/src/lib/floorplan/site-plan/site-plan-layer.tsx +1 -1
- package/src/lib/follow-pascal-presentation.ts +1 -1
- package/src/lib/glb-export.ts +1 -1
- package/src/lib/material-paint.ts +1 -1
- package/src/lib/measurements.test.ts +2 -1
- package/src/lib/mezzanine-selection.test.ts +1 -1
- package/src/lib/paint-region-mode.ts +1 -1
- package/src/lib/point-ask/session.ts +1 -2
- package/src/lib/roof-duplication.test.ts +100 -0
- package/src/lib/roof-duplication.ts +30 -5
- package/src/lib/room-handle-drag.ts +2 -12
- package/src/lib/room-name-catalog.test.ts +2 -1
- package/src/lib/room-name-messages.test.ts +2 -1
- package/src/lib/room-name-messages.ts +1 -118
- package/src/lib/room-name-storage.test.ts +2 -1
- package/src/lib/room-selection-commands.ts +2 -2
- package/src/lib/room-selection.test.ts +6 -6
- package/src/lib/room-transform-session.ts +1 -2
- package/src/lib/room-zone-routing.ts +2 -2
- package/src/lib/selection-routing.test.ts +0 -28
- package/src/lib/selection-routing.ts +1 -40
- package/src/lib/sfx-bus.ts +8 -32
- package/src/lib/sfx-player.test.ts +72 -1
- package/src/lib/sfx-player.ts +49 -337
- package/src/lib/viewer-selection.test.ts +38 -10
- package/src/lib/wall-pull.test.ts +1 -1
- package/src/lib/wall-pull.ts +1 -27
- package/src/store/use-editor.tsx +17 -26
- package/src/store/use-floorplan-annotation-visibility.ts +5 -5
- package/src/store/use-wall-region-handles-selection.ts +1 -2
- package/src/components/editor/__bench__/room-label-placement.bench.ts +0 -108
- package/src/components/editor/first-person/build-collider-world.test.ts +0 -596
- package/src/components/editor/first-person/build-collider-world.ts +0 -549
- package/src/components/editor/first-person/ground-holes.test.ts +0 -128
- package/src/components/editor/first-person/ground-holes.ts +0 -130
- package/src/components/editor/first-person/mechanism-targets.test.ts +0 -162
- package/src/components/editor/first-person/mechanism-targets.ts +0 -88
- package/src/components/editor/first-person/terrain-collider.test.ts +0 -132
- package/src/components/editor/first-person/terrain-collider.ts +0 -212
- package/src/components/editor/floorplan-camera-sync.test.ts +0 -249
- package/src/components/editor/floorplan-camera-sync.ts +0 -213
- package/src/components/editor/localized-html.tsx +0 -22
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.test.tsx +0 -236
- package/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +0 -708
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.test.tsx +0 -385
- package/src/components/editor-2d/renderers/floorplan-geometry-renderer.tsx +0 -670
- package/src/components/editor-2d/renderers/floorplan-label-angle.test.ts +0 -89
- package/src/components/editor-2d/renderers/floorplan-label-angle.ts +0 -106
- package/src/components/editor-2d/svg-paths.ts +0 -124
- package/src/components/ui/action-menu/action-button.tsx +0 -89
- package/src/components/ui/action-menu/follow-pascal-toggle.tsx +0 -42
- package/src/components/ui/primitives/button.tsx +0 -69
- package/src/components/ui/primitives/dropdown-menu.tsx +0 -232
- package/src/components/ui/primitives/popover.tsx +0 -42
- package/src/components/ui/primitives/switch.tsx +0 -29
- package/src/components/ui/primitives/tooltip.tsx +0 -57
- package/src/components/ui/scene-loader.tsx +0 -73
- package/src/components/viewer/floorplan-compass-button.tsx +0 -67
- package/src/components/viewer/floorplan-preview-geometry.test.ts +0 -107
- package/src/components/viewer/floorplan-preview-geometry.ts +0 -281
- package/src/components/viewer/floorplan-preview-navigation.test.ts +0 -43
- package/src/components/viewer/floorplan-preview-navigation.ts +0 -62
- package/src/components/viewer/floorplan-preview-visibility.test.ts +0 -35
- package/src/components/viewer/floorplan-preview-visibility.ts +0 -19
- package/src/components/viewer/floorplan-preview.tsx +0 -1065
- package/src/components/viewer/render-settings-menu.tsx +0 -294
- package/src/components/viewer/sky-control.tsx +0 -710
- package/src/components/viewer/use-viewer-camera-navigation-sync.ts +0 -140
- package/src/components/viewer/use-viewer-ui-sounds.test.ts +0 -15
- package/src/components/viewer/use-viewer-ui-sounds.ts +0 -46
- package/src/components/viewer/viewer-controls-bar.tsx +0 -496
- package/src/components/viewer/viewer-hover-label.tsx +0 -42
- package/src/components/viewer/viewer-rooms.tsx +0 -248
- package/src/components/viewer/viewer-scene-header.tsx +0 -286
- package/src/components/viewer/viewer-selection-manager.tsx +0 -124
- package/src/components/viewer/viewer-stage-modes.test.ts +0 -38
- package/src/components/viewer/viewer-stage-modes.ts +0 -49
- package/src/components/viewer/viewer-stage-switcher.tsx +0 -97
- package/src/components/viewer/viewer-stage.test.tsx +0 -47
- package/src/components/viewer/viewer-stage.tsx +0 -221
- package/src/components/walkthrough-hud.tsx +0 -193
- package/src/hooks/use-mobile.ts +0 -19
- package/src/i18n/level-name.ts +0 -39
- package/src/i18n/scene-theme-name.ts +0 -16
- package/src/lib/below-grade.ts +0 -133
- package/src/lib/camera-pose.test.ts +0 -237
- package/src/lib/camera-pose.ts +0 -172
- package/src/lib/floorplan/floorplan-extension.test.ts +0 -42
- package/src/lib/floorplan/floorplan-readonly.test.ts +0 -47
- package/src/lib/floorplan/floorplan-readonly.ts +0 -95
- package/src/lib/floorplan/marks.test.ts +0 -213
- package/src/lib/floorplan/marks.ts +0 -238
- package/src/lib/measurements.ts +0 -8
- package/src/lib/node-display-label.ts +0 -37
- package/src/lib/room-assembly-overlay.test.ts +0 -275
- package/src/lib/room-assembly-overlay.ts +0 -265
- package/src/lib/room-selection.ts +0 -417
- package/src/lib/scene-bounds.test.ts +0 -327
- package/src/lib/scene-bounds.ts +0 -233
- package/src/lib/typing-target.ts +0 -9
- package/src/lib/viewer-selection.ts +0 -595
- package/src/lib/zone-content.ts +0 -133
- package/src/store/camera-pose-store.test.ts +0 -29
- package/src/store/camera-pose-store.ts +0 -27
- package/src/store/navigation-sync-pose-store.test.ts +0 -34
- package/src/store/navigation-sync-pose-store.ts +0 -27
- package/src/store/use-audio.test.ts +0 -146
- package/src/store/use-audio.tsx +0 -169
- package/src/store/use-first-person-hud.ts +0 -40
- package/src/store/use-follow-pascal.test.ts +0 -62
- package/src/store/use-follow-pascal.ts +0 -53
|
@@ -1,1991 +1,30 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
type ElevatorNode,
|
|
8
|
-
emitter,
|
|
9
|
-
getElevatorCabCenterZ,
|
|
10
|
-
getElevatorCabDepth,
|
|
11
|
-
getElevatorCabWidth,
|
|
12
|
-
getElevatorDoorLeafSides,
|
|
13
|
-
getElevatorDoorLeafWidth,
|
|
14
|
-
getElevatorDoorLeafX,
|
|
15
|
-
getElevatorShaftDepth,
|
|
16
|
-
getElevatorShaftWallThickness,
|
|
17
|
-
getElevatorShaftWidth,
|
|
18
|
-
getLevelDisplayName,
|
|
19
|
-
getLevelElevations,
|
|
20
|
-
getResolvedElevatorDoorStyle,
|
|
21
|
-
type InteractionSound,
|
|
22
|
-
itemPrompt,
|
|
23
|
-
nodeMechanism,
|
|
24
|
-
openElevatorDoor,
|
|
25
|
-
operateItem,
|
|
26
|
-
pointInPolygon2D,
|
|
27
|
-
requestElevatorLevel,
|
|
28
|
-
resolveElevatorDispatchTarget,
|
|
29
|
-
resolveElevatorLevels,
|
|
30
|
-
sceneRegistry,
|
|
31
|
-
toggleNodeMechanism,
|
|
32
|
-
useInteractive,
|
|
33
|
-
useScene,
|
|
34
|
-
} from '@pascal-app/core'
|
|
35
|
-
import {
|
|
36
|
-
operablePartFor,
|
|
37
|
-
operableParts,
|
|
38
|
-
type ProceduralItemNode,
|
|
39
|
-
} from '@pascal-app/core/procedural-items'
|
|
40
|
-
import {
|
|
41
|
-
applyWalkthroughCameraClipping,
|
|
42
|
-
BVHEcctrl,
|
|
43
|
-
type BVHEcctrlApi,
|
|
44
|
-
CROUCH_CAPSULE,
|
|
45
|
-
CROUCH_EYE_OFFSET,
|
|
46
|
-
CROUCH_FLOAT_HEIGHT,
|
|
47
|
-
CROUCH_RUN_SPEED,
|
|
48
|
-
CROUCH_WALK_SPEED,
|
|
49
|
-
createDroneInput,
|
|
50
|
-
DRONE_SLOW_MULTIPLIER,
|
|
51
|
-
type DroneLook,
|
|
52
|
-
EYE_LERP_SPEED,
|
|
53
|
-
type MovementInput,
|
|
54
|
-
openingSettled,
|
|
55
|
-
proceduralSettled,
|
|
56
|
-
STAND_CAPSULE,
|
|
57
|
-
STAND_CLEARANCE,
|
|
58
|
-
STAND_FLOAT_HEIGHT,
|
|
59
|
-
seedDroneLook,
|
|
60
|
-
setSurfaceRaycastLayers,
|
|
61
|
-
stepDroneFlight,
|
|
62
|
-
turnFirstPersonLook,
|
|
63
|
-
useViewer,
|
|
64
|
-
WALKTHROUGH_FOV,
|
|
65
|
-
} from '@pascal-app/viewer'
|
|
66
|
-
import { KeyboardControls } from '@react-three/drei'
|
|
67
|
-
import { useFrame, useThree } from '@react-three/fiber'
|
|
68
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
69
|
-
import {
|
|
70
|
-
Box3,
|
|
71
|
-
BoxGeometry,
|
|
72
|
-
Euler,
|
|
73
|
-
type Group,
|
|
74
|
-
Matrix4,
|
|
75
|
-
Mesh,
|
|
76
|
-
MeshBasicMaterial,
|
|
77
|
-
type Object3D,
|
|
78
|
-
type PerspectiveCamera,
|
|
79
|
-
Ray,
|
|
80
|
-
Raycaster,
|
|
81
|
-
Vector2,
|
|
82
|
-
Vector3,
|
|
83
|
-
} from 'three'
|
|
84
|
-
import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh'
|
|
85
|
-
import '../../three-types'
|
|
86
|
-
import {
|
|
87
|
-
closeDoorOpenState,
|
|
88
|
-
closeWindowOpenState,
|
|
89
|
-
DOOR_SWING_OPEN_ANGLE,
|
|
90
|
-
getDisplayedDoorValue,
|
|
91
|
-
getDisplayedWindowValue,
|
|
92
|
-
isOperableWindowType,
|
|
93
|
-
isOperationDoorType,
|
|
94
|
-
toggleDoorOpenState,
|
|
95
|
-
toggleWindowOpenState,
|
|
96
|
-
} from '@pascal-app/core'
|
|
97
|
-
import { isTypingTarget } from '../../lib/typing-target'
|
|
3
|
+
import { useScene } from '@pascal-app/core'
|
|
4
|
+
import { LiveWalkthroughController, useFirstPersonHud, useViewer } from '@pascal-app/viewer'
|
|
5
|
+
import { DroneHud, WalkthroughHud } from '@pascal-app/viewer/ui'
|
|
6
|
+
import { useCallback } from 'react'
|
|
98
7
|
import useEditor from '../../store/use-editor'
|
|
99
|
-
import { useFirstPersonHud, type WalkthroughInteract } from '../../store/use-first-person-hud'
|
|
100
|
-
import { DroneHud, WalkthroughHud } from '../walkthrough-hud'
|
|
101
|
-
import {
|
|
102
|
-
buildFirstPersonColliderWorldFromRegistry,
|
|
103
|
-
deriveFirstPersonSpawn,
|
|
104
|
-
FIRST_PERSON_SPAWN_EYE_HEIGHT,
|
|
105
|
-
type FirstPersonColliderWorld,
|
|
106
|
-
type FirstPersonSpawn,
|
|
107
|
-
} from './first-person/build-collider-world'
|
|
108
|
-
import {
|
|
109
|
-
hasNativeLeaf,
|
|
110
|
-
mechanismHudInteract,
|
|
111
|
-
mechanismTargetIds,
|
|
112
|
-
nativeOpeningIds,
|
|
113
|
-
watchScriptedDoors,
|
|
114
|
-
} from './first-person/mechanism-targets'
|
|
115
8
|
|
|
116
|
-
|
|
117
|
-
const
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
const
|
|
121
|
-
const
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const VOID_FALL_RESPAWN_DEPTH = 12
|
|
126
|
-
const HUD_LABEL_SAMPLE_FRAMES = 10
|
|
127
|
-
|
|
128
|
-
type MovementKeyName = Exclude<keyof MovementInput, 'joystick'>
|
|
129
|
-
|
|
130
|
-
const movementKeyboardBindings: Array<{ name: MovementKeyName; keys: string[] }> = [
|
|
131
|
-
{ name: 'forward', keys: ['ArrowUp', 'KeyW'] },
|
|
132
|
-
{ name: 'backward', keys: ['ArrowDown', 'KeyS'] },
|
|
133
|
-
{ name: 'leftward', keys: ['ArrowLeft', 'KeyA'] },
|
|
134
|
-
{ name: 'rightward', keys: ['ArrowRight', 'KeyD'] },
|
|
135
|
-
{ name: 'jump', keys: ['Space'] },
|
|
136
|
-
{ name: 'run', keys: ['ShiftLeft', 'ShiftRight'] },
|
|
137
|
-
]
|
|
138
|
-
const keyboardMap = movementKeyboardBindings
|
|
139
|
-
const movementKeyToName = new Map<string, MovementKeyName>(
|
|
140
|
-
movementKeyboardBindings.flatMap(({ name, keys }) => keys.map((key) => [key, name] as const)),
|
|
141
|
-
)
|
|
142
|
-
|
|
143
|
-
const inactiveMovementInput: MovementInput = {
|
|
144
|
-
backward: false,
|
|
145
|
-
forward: false,
|
|
146
|
-
jump: false,
|
|
147
|
-
leftward: false,
|
|
148
|
-
rightward: false,
|
|
149
|
-
run: false,
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function getMovementInputForKey(code: string, active: boolean): MovementInput | null {
|
|
153
|
-
const name = movementKeyToName.get(code)
|
|
154
|
-
return name ? ({ [name]: active } as MovementInput) : null
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
function focusFirstPersonCanvas(canvas: HTMLCanvasElement) {
|
|
158
|
-
const activeElement = document.activeElement
|
|
159
|
-
if (activeElement instanceof HTMLElement && !canvas.contains(activeElement)) {
|
|
160
|
-
activeElement.blur()
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
if (!canvas.hasAttribute('tabindex')) {
|
|
164
|
-
canvas.tabIndex = -1
|
|
165
|
-
}
|
|
166
|
-
canvas.focus({ preventScroll: true })
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
const cameraOffset = new Vector3()
|
|
170
|
-
const cameraEuler = new Euler(0, 0, 0, 'YXZ')
|
|
171
|
-
const droneInput = createDroneInput()
|
|
172
|
-
const standClearanceRaycaster = new Raycaster()
|
|
173
|
-
const standClearanceUp = new Vector3(0, 1, 0)
|
|
174
|
-
const centerScreenPoint = new Vector2(0, 0)
|
|
175
|
-
const doorInteractionRaycaster = new Raycaster()
|
|
176
|
-
setSurfaceRaycastLayers(doorInteractionRaycaster.layers)
|
|
177
|
-
const proceduralInteractionRaycaster = new Raycaster()
|
|
178
|
-
setSurfaceRaycastLayers(proceduralInteractionRaycaster.layers)
|
|
179
|
-
const doorLeafBox = new Box3()
|
|
180
|
-
const doorLeafInverseMatrix = new Matrix4()
|
|
181
|
-
const doorLeafLocalHit = new Vector3()
|
|
182
|
-
const doorLeafLocalRay = new Ray()
|
|
183
|
-
const doorLeafMatrix = new Matrix4()
|
|
184
|
-
const doorLeafWorldHit = new Vector3()
|
|
185
|
-
const doorOpeningBox = new Box3()
|
|
186
|
-
const doorOpeningInverseMatrix = new Matrix4()
|
|
187
|
-
const doorOpeningLocalHit = new Vector3()
|
|
188
|
-
const doorOpeningLocalRay = new Ray()
|
|
189
|
-
const doorOpeningMatrix = new Matrix4()
|
|
190
|
-
const doorOpeningWorldHit = new Vector3()
|
|
191
|
-
const elevatorLocalControllerPosition = new Vector3()
|
|
192
|
-
const elevatorInteractionRaycaster = new Raycaster()
|
|
193
|
-
const elevatorColliderMatrix = new Matrix4()
|
|
194
|
-
const elevatorColliderLocalMatrix = new Matrix4()
|
|
195
|
-
const elevatorLocalEyePosition = new Vector3()
|
|
196
|
-
const elevatorWorldControllerPosition = new Vector3()
|
|
197
|
-
const elevatorColliderMaterial = new MeshBasicMaterial({ visible: false })
|
|
198
|
-
const spawnWorldPosition = new Vector3()
|
|
199
|
-
const spawnWorldEuler = new Euler(0, 0, 0, 'YXZ')
|
|
200
|
-
const windowInteractionRaycaster = new Raycaster()
|
|
201
|
-
setSurfaceRaycastLayers(windowInteractionRaycaster.layers)
|
|
202
|
-
const hudBuildingLocalEyePosition = new Vector3()
|
|
203
|
-
const hudWorldEyePosition = new Vector3()
|
|
204
|
-
const hudLevelBounds = new Box3()
|
|
205
|
-
|
|
206
|
-
type ElevatorColliderKind =
|
|
207
|
-
| 'cab-back'
|
|
208
|
-
| 'cab-ceiling'
|
|
209
|
-
| 'cab-door-left'
|
|
210
|
-
| 'cab-door-right'
|
|
211
|
-
| 'cab-door-gate'
|
|
212
|
-
| 'cab-floor'
|
|
213
|
-
| 'cab-left'
|
|
214
|
-
| 'cab-right'
|
|
215
|
-
| 'landing-door-gate'
|
|
216
|
-
| 'landing-door-left'
|
|
217
|
-
| 'landing-door-right'
|
|
218
|
-
| 'shaft-back'
|
|
219
|
-
| 'shaft-front-header'
|
|
220
|
-
| 'shaft-front-left'
|
|
221
|
-
| 'shaft-front-right'
|
|
222
|
-
| 'shaft-left'
|
|
223
|
-
| 'shaft-right'
|
|
224
|
-
| 'shaft-top'
|
|
225
|
-
|
|
226
|
-
type ElevatorColliderUserData = {
|
|
227
|
-
doorWidth?: number
|
|
228
|
-
dynamic?: boolean
|
|
229
|
-
elevatorId: AnyNodeId
|
|
230
|
-
kind: ElevatorColliderKind
|
|
231
|
-
levelId?: AnyNodeId
|
|
232
|
-
localPosition: [number, number, number]
|
|
233
|
-
matrixInitialized?: boolean
|
|
234
|
-
side?: ElevatorDoorSide
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
type ElevatorColliderMesh = Mesh & {
|
|
238
|
-
userData: Mesh['userData'] & ElevatorColliderUserData
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
type FirstPersonInteractableTarget =
|
|
242
|
-
| {
|
|
243
|
-
id: AnyNodeId
|
|
244
|
-
type: 'door' | 'window'
|
|
245
|
-
}
|
|
246
|
-
| {
|
|
247
|
-
id: AnyNodeId
|
|
248
|
-
type: 'item'
|
|
249
|
-
}
|
|
250
|
-
| {
|
|
251
|
-
id: AnyNodeId
|
|
252
|
-
partId?: string
|
|
253
|
-
kind?: 'hinge' | 'slide' | 'spin'
|
|
254
|
-
type: 'procedural'
|
|
255
|
-
}
|
|
256
|
-
| {
|
|
257
|
-
id: AnyNodeId
|
|
258
|
-
type: 'mechanism'
|
|
259
|
-
}
|
|
260
|
-
| {
|
|
261
|
-
action: 'open-door' | 'request-level'
|
|
262
|
-
buttonKind: 'cab' | 'landing'
|
|
263
|
-
id: AnyNodeId
|
|
264
|
-
levelId?: AnyNodeId
|
|
265
|
-
type: 'elevator'
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
type ElevatorButtonTarget = {
|
|
269
|
-
action: 'open-door' | 'request-level'
|
|
270
|
-
buttonKind: 'cab' | 'landing'
|
|
271
|
-
elevatorId: AnyNodeId
|
|
272
|
-
levelId?: AnyNodeId
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
function getLevelChildren(
|
|
276
|
-
level: Extract<AnyNode, { type: 'level' }>,
|
|
277
|
-
nodes: Record<string, AnyNode>,
|
|
278
|
-
) {
|
|
279
|
-
const childIds = new Set<string>(level.children)
|
|
280
|
-
return Object.values(nodes).filter((node) => node.parentId === level.id || childIds.has(node.id))
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
function pointIsInLevelFootprint(
|
|
284
|
-
point: [number, number],
|
|
285
|
-
worldPoint: Vector3,
|
|
286
|
-
level: Extract<AnyNode, { type: 'level' }>,
|
|
287
|
-
nodes: Record<string, AnyNode>,
|
|
288
|
-
) {
|
|
289
|
-
const children = getLevelChildren(level, nodes)
|
|
290
|
-
const slabs = children.filter(
|
|
291
|
-
(node): node is Extract<AnyNode, { type: 'slab' }> =>
|
|
292
|
-
node.type === 'slab' && node.polygon.length >= 3,
|
|
293
|
-
)
|
|
294
|
-
const zones = children.filter(
|
|
295
|
-
(node): node is Extract<AnyNode, { type: 'zone' }> =>
|
|
296
|
-
node.type === 'zone' && node.polygon.length >= 3,
|
|
297
|
-
)
|
|
298
|
-
|
|
299
|
-
if (slabs.length > 0) {
|
|
300
|
-
return slabs.some(
|
|
301
|
-
(slab) =>
|
|
302
|
-
pointInPolygon2D(point, slab.polygon) &&
|
|
303
|
-
!slab.holes.some((hole) => pointInPolygon2D(point, hole)),
|
|
304
|
-
)
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
if (zones.length > 0) {
|
|
308
|
-
if (zones.some((zone) => pointInPolygon2D(point, zone.polygon))) return true
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
const levelObject = sceneRegistry.nodes.get(level.id)
|
|
312
|
-
if (!levelObject) return false
|
|
313
|
-
hudLevelBounds.setFromObject(levelObject)
|
|
314
|
-
return (
|
|
315
|
-
!hudLevelBounds.isEmpty() &&
|
|
316
|
-
worldPoint.x >= hudLevelBounds.min.x &&
|
|
317
|
-
worldPoint.x <= hudLevelBounds.max.x &&
|
|
318
|
-
worldPoint.z >= hudLevelBounds.min.z &&
|
|
319
|
-
worldPoint.z <= hudLevelBounds.max.z
|
|
320
|
-
)
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
function resolveFirstPersonHudLabels(worldPoint: Vector3) {
|
|
324
|
-
const nodes = useScene.getState().nodes
|
|
325
|
-
const levelElevations = getLevelElevations(nodes as Record<AnyNodeId, AnyNode>)
|
|
326
|
-
|
|
327
|
-
for (const building of Object.values(nodes)) {
|
|
328
|
-
if (building.type !== 'building') continue
|
|
329
|
-
const buildingObject = sceneRegistry.nodes.get(building.id)
|
|
330
|
-
if (!buildingObject) continue
|
|
331
|
-
|
|
332
|
-
buildingObject.updateWorldMatrix(true, true)
|
|
333
|
-
hudBuildingLocalEyePosition.copy(worldPoint)
|
|
334
|
-
buildingObject.worldToLocal(hudBuildingLocalEyePosition)
|
|
335
|
-
|
|
336
|
-
const levels = Object.values(nodes)
|
|
337
|
-
.filter((node) => node.type === 'level')
|
|
338
|
-
.filter((level) => levelElevations.get(level.id)?.buildingId === building.id)
|
|
339
|
-
.sort(
|
|
340
|
-
(left, right) =>
|
|
341
|
-
(levelElevations.get(left.id)?.baseY ?? 0) - (levelElevations.get(right.id)?.baseY ?? 0),
|
|
342
|
-
)
|
|
343
|
-
|
|
344
|
-
let activeLevel: (typeof levels)[number] | null = null
|
|
345
|
-
for (const level of levels) {
|
|
346
|
-
const elevation = levelElevations.get(level.id)
|
|
347
|
-
if (!elevation) continue
|
|
348
|
-
if (
|
|
349
|
-
hudBuildingLocalEyePosition.y >= elevation.baseY - 0.5 &&
|
|
350
|
-
hudBuildingLocalEyePosition.y < elevation.baseY + elevation.height + 0.5
|
|
351
|
-
) {
|
|
352
|
-
activeLevel = level
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
if (!activeLevel) continue
|
|
356
|
-
|
|
357
|
-
const point: [number, number] = [hudBuildingLocalEyePosition.x, hudBuildingLocalEyePosition.z]
|
|
358
|
-
if (!pointIsInLevelFootprint(point, worldPoint, activeLevel, nodes)) continue
|
|
359
|
-
|
|
360
|
-
const zone = getLevelChildren(activeLevel, nodes).find(
|
|
361
|
-
(node) =>
|
|
362
|
-
node.type === 'zone' && node.polygon.length >= 3 && pointInPolygon2D(point, node.polygon),
|
|
363
|
-
)
|
|
364
|
-
|
|
365
|
-
return {
|
|
366
|
-
floorLabel: getLevelDisplayName(activeLevel),
|
|
367
|
-
zoneLabel: zone?.type === 'zone' ? zone.name : null,
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
return { floorLabel: null, zoneLabel: null }
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
function resolveHudInteract(target: FirstPersonInteractableTarget | null): WalkthroughInteract {
|
|
375
|
-
if (!target) return null
|
|
376
|
-
if (target.type === 'elevator') {
|
|
377
|
-
return {
|
|
378
|
-
label: target.action === 'open-door' ? 'door button' : 'elevator button',
|
|
379
|
-
verb: 'press',
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
const node = useScene.getState().nodes[target.id]
|
|
384
|
-
if (target.type === 'mechanism') return node ? mechanismHudInteract(node) : null
|
|
385
|
-
if (target.type === 'item') {
|
|
386
|
-
if (node?.type !== 'item' || !node.asset.interactive) return null
|
|
387
|
-
const { label, verb } = itemPrompt(
|
|
388
|
-
target.id,
|
|
389
|
-
node.name ?? node.asset.name,
|
|
390
|
-
node.asset.interactive,
|
|
391
|
-
)
|
|
392
|
-
return { label, verb }
|
|
393
|
-
}
|
|
394
|
-
if (target.type === 'procedural') {
|
|
395
|
-
if (node?.type !== 'procedural-item') return null
|
|
396
|
-
const procedural = node as ProceduralItemNode
|
|
397
|
-
const parts = operableParts(procedural.recipe)
|
|
398
|
-
if (parts.length === 0 && procedural.recipe.parts.some((part) => part.light)) {
|
|
399
|
-
const state = useInteractive.getState()
|
|
400
|
-
const isOn = state.procedural[target.id]?.lightsOn ?? state.lampDefault
|
|
401
|
-
return { label: procedural.name ?? 'Lights', verb: isOn ? 'turn off' : 'turn on' }
|
|
402
|
-
}
|
|
403
|
-
const part = target.partId ? parts.find((entry) => entry.id === target.partId) : undefined
|
|
404
|
-
const active = useInteractive.getState().procedural[target.id]?.parts
|
|
405
|
-
const isOn = part ? Boolean(active?.[part.id]) : parts.some((entry) => active?.[entry.id])
|
|
406
|
-
const kind = part?.kind ?? (parts.every((entry) => entry.kind === 'spin') ? 'spin' : 'hinge')
|
|
407
|
-
return {
|
|
408
|
-
label: part?.label ?? procedural.name ?? 'item',
|
|
409
|
-
verb: kind === 'spin' ? (isOn ? 'turn off' : 'turn on') : isOn ? 'close' : 'open',
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
if (target.type === 'window') {
|
|
413
|
-
if (node?.type !== 'window') return null
|
|
414
|
-
const isOpen = getDisplayedWindowValue(target.id, node.operationState) > 0
|
|
415
|
-
return { label: node.name || 'window', verb: isOpen ? 'close' : 'open' }
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
if (node?.type !== 'door') return null
|
|
419
|
-
const isOpen = isOperationDoorType(node.doorType)
|
|
420
|
-
? getDisplayedDoorValue(target.id, 'operationState', node.operationState) > 0
|
|
421
|
-
: getDisplayedDoorValue(target.id, 'swingAngle', node.swingAngle) > 0
|
|
422
|
-
return { label: node.name || 'door', verb: isOpen ? 'close' : 'open' }
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
/** E plays what the viewer's callout plays for the same door, window or switch. */
|
|
426
|
-
function playTargetSound(target: FirstPersonInteractableTarget) {
|
|
427
|
-
const verb = resolveHudInteract(target)?.verb
|
|
428
|
-
if (!verb || verb === 'press') return
|
|
429
|
-
const node = useScene.getState().nodes[target.id]
|
|
430
|
-
// A recipe's door or lid swings and claps shut, its drawer slides; both land on the motion's end.
|
|
431
|
-
if (target.type === 'procedural' && (verb === 'open' || verb === 'close')) {
|
|
432
|
-
if (verb === 'open') {
|
|
433
|
-
emitter.emit('interaction:sound', 'panel-open')
|
|
434
|
-
return
|
|
435
|
-
}
|
|
436
|
-
if (target.kind !== 'slide') emitter.emit('interaction:sound', 'panel-swing')
|
|
437
|
-
void proceduralSettled(target.id).then(() => emitter.emit('interaction:sound', 'panel-close'))
|
|
438
|
-
return
|
|
439
|
-
}
|
|
440
|
-
let sound: InteractionSound
|
|
441
|
-
if (verb !== 'open' && verb !== 'close')
|
|
442
|
-
sound = verb === 'turn on' || verb === 'play' ? 'switch-on' : 'switch-off'
|
|
443
|
-
else if (node?.type === 'door' && node.doorType?.startsWith('garage-')) sound = 'garage'
|
|
444
|
-
else if (node?.type === 'window') sound = verb === 'open' ? 'window-open' : 'window-close'
|
|
445
|
-
else sound = verb === 'open' ? 'door-open' : 'door-close'
|
|
446
|
-
// A built-in leaf or sash swings shut, then latches on the frame it lands.
|
|
447
|
-
if (
|
|
448
|
-
(sound === 'door-close' || sound === 'window-close') &&
|
|
449
|
-
(target.type === 'door' || target.type === 'window') &&
|
|
450
|
-
node
|
|
451
|
-
) {
|
|
452
|
-
emitter.emit('interaction:sound', sound === 'door-close' ? 'door-swing' : 'window-swing')
|
|
453
|
-
void openingSettled(node).then(() => emitter.emit('interaction:sound', sound))
|
|
454
|
-
return
|
|
455
|
-
}
|
|
456
|
-
emitter.emit('interaction:sound', sound)
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
function resolveElevatorButtonTarget(object: Object3D): ElevatorButtonTarget | null {
|
|
460
|
-
let current: Object3D | null = object
|
|
461
|
-
|
|
462
|
-
while (current) {
|
|
463
|
-
const candidate = (
|
|
464
|
-
current.userData as {
|
|
465
|
-
elevatorButton?: {
|
|
466
|
-
action?: unknown
|
|
467
|
-
disabled?: unknown
|
|
468
|
-
elevatorId?: unknown
|
|
469
|
-
kind?: unknown
|
|
470
|
-
levelId?: unknown
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
).elevatorButton
|
|
474
|
-
|
|
475
|
-
if (candidate?.disabled === true) {
|
|
476
|
-
return null
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
if (typeof candidate?.elevatorId === 'string' && candidate.kind === 'cab') {
|
|
480
|
-
const action = candidate.action === 'open-door' ? 'open-door' : 'request-level'
|
|
481
|
-
if (action === 'open-door') {
|
|
482
|
-
return {
|
|
483
|
-
action,
|
|
484
|
-
buttonKind: candidate.kind,
|
|
485
|
-
elevatorId: candidate.elevatorId as AnyNodeId,
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
if (
|
|
491
|
-
typeof candidate?.elevatorId === 'string' &&
|
|
492
|
-
typeof candidate.levelId === 'string' &&
|
|
493
|
-
(candidate.kind === 'cab' || candidate.kind === 'landing')
|
|
494
|
-
) {
|
|
495
|
-
return {
|
|
496
|
-
action: 'request-level',
|
|
497
|
-
buttonKind: candidate.kind,
|
|
498
|
-
elevatorId: candidate.elevatorId as AnyNodeId,
|
|
499
|
-
levelId: candidate.levelId as AnyNodeId,
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
current = current.parent
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
return null
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
function getInteractableTargetKey(target: FirstPersonInteractableTarget | null) {
|
|
510
|
-
if (!target) return null
|
|
511
|
-
return target.type === 'elevator'
|
|
512
|
-
? `${target.type}:${target.id}:${target.levelId}`
|
|
513
|
-
: target.type === 'procedural'
|
|
514
|
-
? `${target.type}:${target.id}:${target.partId ?? 'all'}`
|
|
515
|
-
: `${target.type}:${target.id}`
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
function isDynamicElevatorCollider(kind: ElevatorColliderKind) {
|
|
519
|
-
return kind.startsWith('cab-') || kind.startsWith('landing-door')
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
function isInsideElevatorCab(
|
|
523
|
-
elevator: ElevatorNode,
|
|
524
|
-
runtime: NonNullable<ReturnType<typeof useInteractive.getState>['elevators'][AnyNodeId]>,
|
|
525
|
-
localEyePosition: Vector3,
|
|
526
|
-
) {
|
|
527
|
-
const halfWidth = getElevatorCabWidth(elevator) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING
|
|
528
|
-
const halfDepth = getElevatorCabDepth(elevator) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING
|
|
529
|
-
const cabCenterZ = getElevatorCabCenterZ(elevator)
|
|
530
|
-
const cabHeight = Math.max(elevator.cabHeight, 1.4)
|
|
531
|
-
|
|
532
|
-
return (
|
|
533
|
-
Math.abs(localEyePosition.x) <= Math.max(halfWidth, 0.24) &&
|
|
534
|
-
Math.abs(localEyePosition.z - cabCenterZ) <= Math.max(halfDepth, 0.24) &&
|
|
535
|
-
localEyePosition.y >= runtime.carY + 0.35 &&
|
|
536
|
-
localEyePosition.y <= runtime.carY + cabHeight + 0.7
|
|
537
|
-
)
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
function createElevatorColliderMesh(
|
|
541
|
-
elevatorId: AnyNodeId,
|
|
542
|
-
kind: ElevatorColliderKind,
|
|
543
|
-
size: [number, number, number],
|
|
544
|
-
localPosition: [number, number, number],
|
|
545
|
-
userData: Partial<ElevatorColliderUserData> = {},
|
|
546
|
-
) {
|
|
547
|
-
const geometry = new BoxGeometry(size[0], size[1], size[2])
|
|
548
|
-
|
|
549
|
-
const bvhGeometry = geometry as typeof geometry & {
|
|
550
|
-
computeBoundsTree?: typeof computeBoundsTree
|
|
551
|
-
disposeBoundsTree?: typeof disposeBoundsTree
|
|
552
|
-
}
|
|
553
|
-
;(bvhGeometry as any).computeBoundsTree = computeBoundsTree
|
|
554
|
-
;(bvhGeometry as any).disposeBoundsTree = disposeBoundsTree
|
|
555
|
-
bvhGeometry.computeBoundsTree?.({
|
|
556
|
-
targetLeafSize: 12,
|
|
557
|
-
strategy: 0,
|
|
558
|
-
} as never)
|
|
559
|
-
bvhGeometry.computeBoundingBox()
|
|
560
|
-
|
|
561
|
-
const mesh = new Mesh(bvhGeometry, elevatorColliderMaterial) as unknown as ElevatorColliderMesh
|
|
562
|
-
mesh.raycast = acceleratedRaycast
|
|
563
|
-
mesh.matrixAutoUpdate = false
|
|
564
|
-
mesh.visible = true
|
|
565
|
-
mesh.userData = {
|
|
566
|
-
...userData,
|
|
567
|
-
dynamic: isDynamicElevatorCollider(kind),
|
|
568
|
-
elevatorId,
|
|
569
|
-
excludeCollisionCheck: false,
|
|
570
|
-
excludeFloatHit: false,
|
|
571
|
-
friction: 0.8,
|
|
572
|
-
kind,
|
|
573
|
-
localPosition,
|
|
574
|
-
matrixInitialized: false,
|
|
575
|
-
restitution: 0.03,
|
|
576
|
-
type: 'ELEVATOR_COLLIDER',
|
|
577
|
-
}
|
|
578
|
-
return mesh
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
function buildElevatorColliderMeshes(): ElevatorColliderMesh[] {
|
|
582
|
-
const nodes = useScene.getState().nodes
|
|
583
|
-
const meshes: ElevatorColliderMesh[] = []
|
|
584
|
-
|
|
585
|
-
for (const elevatorId of sceneRegistry.byType.elevator!) {
|
|
586
|
-
const typedElevatorId = elevatorId as AnyNodeId
|
|
587
|
-
const node = nodes[typedElevatorId]
|
|
588
|
-
if (node?.type !== 'elevator' || node.visible === false) continue
|
|
589
|
-
|
|
590
|
-
const { entries, shaftBaseY, shaftTopY, totalHeight } = resolveElevatorLevels(node, nodes)
|
|
591
|
-
const cabWidth = getElevatorCabWidth(node)
|
|
592
|
-
const cabDepth = getElevatorCabDepth(node)
|
|
593
|
-
const shaftWidth = getElevatorShaftWidth(node, cabWidth)
|
|
594
|
-
const shaftDepth = getElevatorShaftDepth(node, cabDepth)
|
|
595
|
-
const cabHeight = Math.max(node.cabHeight, 1.4)
|
|
596
|
-
const doorWidth = Math.min(Math.max(node.doorWidth, 0.45), cabWidth - 0.18, shaftWidth - 0.18)
|
|
597
|
-
const doorHeight = Math.min(Math.max(node.doorHeight, 1.2), cabHeight - 0.1)
|
|
598
|
-
const doorStyle = getResolvedElevatorDoorStyle(node.doorStyle)
|
|
599
|
-
const shaftHeight = Math.max(totalHeight, cabHeight + 0.3)
|
|
600
|
-
const wallThickness = getElevatorShaftWallThickness(node)
|
|
601
|
-
const cabFloorWidth = Math.max(cabWidth - ELEVATOR_COLLIDER_HORIZONTAL_PADDING * 2, 0.48)
|
|
602
|
-
const cabFloorDepth = Math.max(cabDepth - ELEVATOR_COLLIDER_HORIZONTAL_PADDING * 2, 0.48)
|
|
603
|
-
const frontWallZ = -shaftDepth / 2 - wallThickness / 2
|
|
604
|
-
const frontZ = frontWallZ - wallThickness / 2 - 0.018
|
|
605
|
-
const cabCenterZ = -shaftDepth / 2 + cabDepth / 2
|
|
606
|
-
const leafWidth = getElevatorDoorLeafWidth(doorWidth, doorStyle)
|
|
607
|
-
const doorLeafSides = getElevatorDoorLeafSides(doorStyle)
|
|
608
|
-
const resolvedShaftTopY = Math.max(shaftTopY, shaftBaseY + shaftHeight)
|
|
609
|
-
|
|
610
|
-
meshes.push(
|
|
611
|
-
createElevatorColliderMesh(
|
|
612
|
-
typedElevatorId,
|
|
613
|
-
'shaft-back',
|
|
614
|
-
[shaftWidth + wallThickness * 2, shaftHeight, wallThickness],
|
|
615
|
-
[0, shaftBaseY + shaftHeight / 2, shaftDepth / 2 + wallThickness / 2],
|
|
616
|
-
),
|
|
617
|
-
createElevatorColliderMesh(
|
|
618
|
-
typedElevatorId,
|
|
619
|
-
'shaft-left',
|
|
620
|
-
[wallThickness, shaftHeight, shaftDepth + wallThickness * 2],
|
|
621
|
-
[-shaftWidth / 2 - wallThickness / 2, shaftBaseY + shaftHeight / 2, 0],
|
|
622
|
-
),
|
|
623
|
-
createElevatorColliderMesh(
|
|
624
|
-
typedElevatorId,
|
|
625
|
-
'shaft-right',
|
|
626
|
-
[wallThickness, shaftHeight, shaftDepth + wallThickness * 2],
|
|
627
|
-
[shaftWidth / 2 + wallThickness / 2, shaftBaseY + shaftHeight / 2, 0],
|
|
628
|
-
),
|
|
629
|
-
createElevatorColliderMesh(
|
|
630
|
-
typedElevatorId,
|
|
631
|
-
'shaft-top',
|
|
632
|
-
[shaftWidth + wallThickness * 2, wallThickness, shaftDepth + wallThickness * 2],
|
|
633
|
-
[0, shaftBaseY + shaftHeight - wallThickness / 2, 0],
|
|
634
|
-
),
|
|
635
|
-
createElevatorColliderMesh(
|
|
636
|
-
typedElevatorId,
|
|
637
|
-
'cab-floor',
|
|
638
|
-
[cabFloorWidth, ELEVATOR_COLLIDER_FLOOR_THICKNESS, cabFloorDepth],
|
|
639
|
-
[0, ELEVATOR_COLLIDER_FLOOR_THICKNESS / 2, cabCenterZ],
|
|
640
|
-
),
|
|
641
|
-
createElevatorColliderMesh(
|
|
642
|
-
typedElevatorId,
|
|
643
|
-
'cab-ceiling',
|
|
644
|
-
[cabWidth, wallThickness, cabDepth],
|
|
645
|
-
[0, cabHeight - wallThickness / 2, cabCenterZ],
|
|
646
|
-
),
|
|
647
|
-
createElevatorColliderMesh(
|
|
648
|
-
typedElevatorId,
|
|
649
|
-
'cab-back',
|
|
650
|
-
[cabWidth, cabHeight, wallThickness],
|
|
651
|
-
[0, cabHeight / 2, cabCenterZ + cabDepth / 2 - wallThickness / 2],
|
|
652
|
-
),
|
|
653
|
-
createElevatorColliderMesh(
|
|
654
|
-
typedElevatorId,
|
|
655
|
-
'cab-left',
|
|
656
|
-
[wallThickness, cabHeight, cabDepth],
|
|
657
|
-
[-cabWidth / 2 + wallThickness / 2, cabHeight / 2, cabCenterZ],
|
|
658
|
-
),
|
|
659
|
-
createElevatorColliderMesh(
|
|
660
|
-
typedElevatorId,
|
|
661
|
-
'cab-right',
|
|
662
|
-
[wallThickness, cabHeight, cabDepth],
|
|
663
|
-
[cabWidth / 2 - wallThickness / 2, cabHeight / 2, cabCenterZ],
|
|
664
|
-
),
|
|
665
|
-
createElevatorColliderMesh(
|
|
666
|
-
typedElevatorId,
|
|
667
|
-
'cab-door-gate',
|
|
668
|
-
[doorWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH],
|
|
669
|
-
[0, doorHeight / 2, frontZ],
|
|
670
|
-
{ doorWidth },
|
|
671
|
-
),
|
|
672
|
-
...doorLeafSides.map((side) =>
|
|
673
|
-
createElevatorColliderMesh(
|
|
674
|
-
typedElevatorId,
|
|
675
|
-
side === 'left' ? 'cab-door-left' : 'cab-door-right',
|
|
676
|
-
[leafWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH],
|
|
677
|
-
[0, doorHeight / 2, frontZ],
|
|
678
|
-
{ doorWidth, side },
|
|
679
|
-
),
|
|
680
|
-
),
|
|
681
|
-
)
|
|
682
|
-
|
|
683
|
-
const entrySpans = entries.map((entry, index) => {
|
|
684
|
-
const nextEntry = entries[index + 1]
|
|
685
|
-
return {
|
|
686
|
-
entry,
|
|
687
|
-
levelTopY: Math.max(nextEntry?.baseY ?? resolvedShaftTopY, entry.baseY + doorHeight + 0.24),
|
|
688
|
-
}
|
|
689
|
-
})
|
|
690
|
-
|
|
691
|
-
for (const { entry, levelTopY } of entrySpans) {
|
|
692
|
-
const wallDepth = wallThickness
|
|
693
|
-
const levelHeight = Math.max(levelTopY - entry.baseY, doorHeight + 0.24)
|
|
694
|
-
const jambWidth = Math.max((shaftWidth - doorWidth) / 2, 0.08)
|
|
695
|
-
const jambCenterOffset = doorWidth / 2 + jambWidth / 2
|
|
696
|
-
const headerHeight = Math.max(levelHeight - doorHeight, 0.14)
|
|
697
|
-
|
|
698
|
-
meshes.push(
|
|
699
|
-
createElevatorColliderMesh(
|
|
700
|
-
typedElevatorId,
|
|
701
|
-
'shaft-front-left',
|
|
702
|
-
[jambWidth, levelHeight, wallDepth],
|
|
703
|
-
[-jambCenterOffset, entry.baseY + levelHeight / 2, frontWallZ],
|
|
704
|
-
),
|
|
705
|
-
createElevatorColliderMesh(
|
|
706
|
-
typedElevatorId,
|
|
707
|
-
'shaft-front-right',
|
|
708
|
-
[jambWidth, levelHeight, wallDepth],
|
|
709
|
-
[jambCenterOffset, entry.baseY + levelHeight / 2, frontWallZ],
|
|
710
|
-
),
|
|
711
|
-
createElevatorColliderMesh(
|
|
712
|
-
typedElevatorId,
|
|
713
|
-
'shaft-front-header',
|
|
714
|
-
[shaftWidth, headerHeight, wallDepth],
|
|
715
|
-
[0, entry.baseY + doorHeight + headerHeight / 2, frontWallZ],
|
|
716
|
-
),
|
|
717
|
-
createElevatorColliderMesh(
|
|
718
|
-
typedElevatorId,
|
|
719
|
-
'landing-door-gate',
|
|
720
|
-
[doorWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH],
|
|
721
|
-
[0, entry.baseY + doorHeight / 2, frontZ - 0.02],
|
|
722
|
-
{ doorWidth, levelId: entry.id },
|
|
723
|
-
),
|
|
724
|
-
...doorLeafSides.map((side) =>
|
|
725
|
-
createElevatorColliderMesh(
|
|
726
|
-
typedElevatorId,
|
|
727
|
-
side === 'left' ? 'landing-door-left' : 'landing-door-right',
|
|
728
|
-
[leafWidth, doorHeight, ELEVATOR_COLLIDER_DOOR_DEPTH],
|
|
729
|
-
[0, entry.baseY + doorHeight / 2, frontZ - 0.02],
|
|
730
|
-
{ doorWidth, levelId: entry.id, side },
|
|
731
|
-
),
|
|
732
|
-
),
|
|
733
|
-
)
|
|
734
|
-
}
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
return meshes
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
function disposeElevatorColliderMeshes(meshes: ElevatorColliderMesh[]) {
|
|
741
|
-
for (const mesh of meshes) {
|
|
742
|
-
const geometry = mesh.geometry as typeof mesh.geometry & {
|
|
743
|
-
disposeBoundsTree?: typeof disposeBoundsTree
|
|
744
|
-
}
|
|
745
|
-
geometry.disposeBoundsTree?.()
|
|
746
|
-
geometry.dispose()
|
|
747
|
-
}
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
const resolvePlacedSpawnNode = (
|
|
751
|
-
nodes: ReturnType<typeof useScene.getState>['nodes'],
|
|
752
|
-
_levelId: string | null,
|
|
753
|
-
) => {
|
|
754
|
-
const candidates = Object.values(nodes).filter((node) => node.type === 'spawn')
|
|
755
|
-
if (candidates.length === 0) return null
|
|
756
|
-
|
|
757
|
-
return [...candidates].sort((a, b) => a.id.localeCompare(b.id))[0] ?? null
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
export const FirstPersonControls = () => {
|
|
761
|
-
const { camera, gl } = useThree()
|
|
762
|
-
const selectedLevelId = useViewer((state) => state.selection.levelId)
|
|
763
|
-
const placedSpawnNode = useScene((state) => resolvePlacedSpawnNode(state.nodes, selectedLevelId))
|
|
764
|
-
const isDroneMode = useEditor((state) => state.firstPersonMovementMode === 'drone')
|
|
765
|
-
const controllerRef = useRef<BVHEcctrlApi | null>(null)
|
|
766
|
-
const movementInputRef = useRef<MovementInput>({ ...inactiveMovementInput })
|
|
767
|
-
const hadPointerLockRef = useRef(false)
|
|
768
|
-
const lookRef = useRef<DroneLook>({ yaw: 0, pitch: 0 })
|
|
769
|
-
const interactableTargetRef = useRef<FirstPersonInteractableTarget | null>(null)
|
|
770
|
-
const hudLabelFrameRef = useRef(HUD_LABEL_SAMPLE_FRAMES - 1)
|
|
771
|
-
const crouchKeyRef = useRef(false)
|
|
772
|
-
const droneAscendKeyRef = useRef(false)
|
|
773
|
-
const droneSlowKeyRef = useRef(false)
|
|
774
|
-
const droneDescendKeyRef = useRef(false)
|
|
775
|
-
const droneVelocityRef = useRef(new Vector3())
|
|
776
|
-
const suspendRef = useRef(false)
|
|
777
|
-
const eyeOffsetRef = useRef(CAMERA_EYE_OFFSET)
|
|
778
|
-
const [crouched, setCrouched] = useState(false)
|
|
779
|
-
const captureShutterHold = useEditor((state) => state.captureShutterHold)
|
|
780
|
-
const [isElevatorRideLocked, setIsElevatorRideLocked] = useState(false)
|
|
781
|
-
const ridingElevatorRef = useRef<{
|
|
782
|
-
elevatorId: AnyNodeId
|
|
783
|
-
localControllerY: number | null
|
|
784
|
-
previousCarY: number
|
|
785
|
-
} | null>(null)
|
|
786
|
-
const rideLockedRef = useRef(false)
|
|
787
|
-
const worldRef = useRef<FirstPersonColliderWorld | null>(null)
|
|
788
|
-
const elevatorColliderMeshesRef = useRef<ElevatorColliderMesh[]>([])
|
|
789
|
-
const [world, setWorld] = useState<FirstPersonColliderWorld | null>(null)
|
|
790
|
-
const [elevatorColliderMeshes, setElevatorColliderMeshes] = useState<ElevatorColliderMesh[]>([])
|
|
791
|
-
const [controllerStart, setControllerStart] = useState<{
|
|
792
|
-
position: [number, number, number]
|
|
793
|
-
yaw: number
|
|
794
|
-
} | null>(null)
|
|
795
|
-
|
|
796
|
-
useEffect(() => {
|
|
797
|
-
if (!(camera as PerspectiveCamera).isPerspectiveCamera) return
|
|
798
|
-
return applyWalkthroughCameraClipping(camera as PerspectiveCamera)
|
|
799
|
-
}, [camera])
|
|
800
|
-
|
|
801
|
-
useEffect(() => {
|
|
802
|
-
const previousCameraMode = useViewer.getState().cameraMode
|
|
803
|
-
if (previousCameraMode === 'orthographic') {
|
|
804
|
-
useViewer.getState().setCameraMode('perspective')
|
|
805
|
-
}
|
|
806
|
-
return () => {
|
|
807
|
-
if (previousCameraMode === 'orthographic') {
|
|
808
|
-
useViewer.getState().setCameraMode('orthographic')
|
|
809
|
-
}
|
|
810
|
-
}
|
|
811
|
-
}, [])
|
|
812
|
-
|
|
813
|
-
// While a snapshot is being framed the capture rig owns the fov (the user is
|
|
814
|
-
// driving it from the overlay's slider), so walkthrough neither applies its
|
|
815
|
-
// own nor restores one underneath it. Read imperatively: this must not re-run
|
|
816
|
-
// — and therefore restore — when capture mode toggles mid-walkthrough.
|
|
817
|
-
useEffect(() => {
|
|
818
|
-
const perspectiveCamera = camera as PerspectiveCamera
|
|
819
|
-
if (!perspectiveCamera.isPerspectiveCamera) return
|
|
820
|
-
if (useEditor.getState().isCaptureMode) return
|
|
821
|
-
const previousFov = perspectiveCamera.fov
|
|
822
|
-
perspectiveCamera.fov = WALKTHROUGH_FOV
|
|
823
|
-
perspectiveCamera.updateProjectionMatrix()
|
|
824
|
-
return () => {
|
|
825
|
-
if (useEditor.getState().isCaptureMode) return
|
|
826
|
-
perspectiveCamera.fov = previousFov
|
|
827
|
-
perspectiveCamera.updateProjectionMatrix()
|
|
828
|
-
}
|
|
829
|
-
}, [camera])
|
|
830
|
-
|
|
831
|
-
useEffect(() => {
|
|
832
|
-
useFirstPersonHud.getState().reset()
|
|
833
|
-
return () => useFirstPersonHud.getState().reset()
|
|
834
|
-
}, [])
|
|
835
|
-
|
|
836
|
-
const replaceColliderWorld = useCallback((nextWorld: FirstPersonColliderWorld | null) => {
|
|
837
|
-
worldRef.current?.dispose()
|
|
838
|
-
worldRef.current = nextWorld
|
|
839
|
-
setWorld(nextWorld)
|
|
840
|
-
}, [])
|
|
841
|
-
|
|
842
|
-
const replaceElevatorColliderMeshes = useCallback((nextMeshes: ElevatorColliderMesh[]) => {
|
|
843
|
-
disposeElevatorColliderMeshes(elevatorColliderMeshesRef.current)
|
|
844
|
-
elevatorColliderMeshesRef.current = nextMeshes
|
|
845
|
-
setElevatorColliderMeshes(nextMeshes)
|
|
9
|
+
export function FirstPersonControls() {
|
|
10
|
+
const capture = useEditor((state) => state.isCaptureMode)
|
|
11
|
+
const paused = useEditor((state) => state.captureShutterHold)
|
|
12
|
+
// Read at walkthrough start and end, as the capture rig may own the FOV by then.
|
|
13
|
+
const captureOwnsFov = useCallback(() => useEditor.getState().isCaptureMode, [])
|
|
14
|
+
const onExit = useCallback(() => {
|
|
15
|
+
const editor = useEditor.getState()
|
|
16
|
+
if (editor.isCaptureMode) editor.setCaptureMode(false)
|
|
17
|
+
else editor.setFirstPersonMode(false)
|
|
846
18
|
}, [])
|
|
847
19
|
|
|
848
|
-
const rebuildColliderWorld = useCallback(() => {
|
|
849
|
-
replaceColliderWorld(buildFirstPersonColliderWorldFromRegistry())
|
|
850
|
-
replaceElevatorColliderMeshes(buildElevatorColliderMeshes())
|
|
851
|
-
}, [replaceColliderWorld, replaceElevatorColliderMeshes])
|
|
852
|
-
|
|
853
|
-
const setElevatorRideLocked = useCallback((locked: boolean) => {
|
|
854
|
-
if (rideLockedRef.current === locked) return
|
|
855
|
-
rideLockedRef.current = locked
|
|
856
|
-
setIsElevatorRideLocked(locked)
|
|
857
|
-
}, [])
|
|
858
|
-
|
|
859
|
-
const setControllerApi = useCallback((api: BVHEcctrlApi | null) => {
|
|
860
|
-
controllerRef.current = api
|
|
861
|
-
if (api) {
|
|
862
|
-
api.setMovement(movementInputRef.current)
|
|
863
|
-
}
|
|
864
|
-
}, [])
|
|
865
|
-
|
|
866
|
-
const resolveInteractableDoorId = useCallback((): AnyNodeId | null => {
|
|
867
|
-
const nodes = useScene.getState().nodes
|
|
868
|
-
camera.updateMatrixWorld(true)
|
|
869
|
-
doorInteractionRaycaster.setFromCamera(centerScreenPoint, camera)
|
|
870
|
-
|
|
871
|
-
let closestDoorId: AnyNodeId | null = null
|
|
872
|
-
let closestDistance = DOOR_INTERACTION_DISTANCE
|
|
873
|
-
|
|
874
|
-
for (const doorId of sceneRegistry.byType.door!) {
|
|
875
|
-
const node = nodes[doorId as AnyNodeId]
|
|
876
|
-
if (node?.type !== 'door') continue
|
|
877
|
-
if (node.openingKind === 'opening') continue
|
|
878
|
-
if (!hasNativeLeaf(node)) continue
|
|
879
|
-
if (node.segments.every((segment) => segment.type === 'empty')) continue
|
|
880
|
-
|
|
881
|
-
const object = sceneRegistry.nodes.get(doorId)
|
|
882
|
-
if (!object) continue
|
|
883
|
-
|
|
884
|
-
object.updateWorldMatrix(true, true)
|
|
885
|
-
|
|
886
|
-
const placementHit = doorInteractionRaycaster
|
|
887
|
-
.intersectObject(object, true)
|
|
888
|
-
.find((intersection) => intersection.distance <= DOOR_INTERACTION_DISTANCE)
|
|
889
|
-
if (placementHit && placementHit.distance < closestDistance) {
|
|
890
|
-
closestDoorId = doorId as AnyNodeId
|
|
891
|
-
closestDistance = placementHit.distance
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
const leafW = node.width - 2 * node.frameThickness
|
|
895
|
-
const leafH = node.height - node.frameThickness
|
|
896
|
-
if (leafW <= 0 || leafH <= 0) continue
|
|
897
|
-
|
|
898
|
-
const leafCenterY = -node.frameThickness / 2
|
|
899
|
-
|
|
900
|
-
if (isOperationDoorType(node.doorType)) {
|
|
901
|
-
doorOpeningMatrix
|
|
902
|
-
.copy(object.matrixWorld)
|
|
903
|
-
.multiply(new Matrix4().makeTranslation(0, leafCenterY, 0))
|
|
904
|
-
doorOpeningInverseMatrix.copy(doorOpeningMatrix).invert()
|
|
905
|
-
doorOpeningBox.min.set(-leafW / 2, -leafH / 2, -DOOR_LEAF_INTERACTION_DEPTH / 2)
|
|
906
|
-
doorOpeningBox.max.set(leafW / 2, leafH / 2, DOOR_LEAF_INTERACTION_DEPTH / 2)
|
|
907
|
-
doorOpeningLocalRay
|
|
908
|
-
.copy(doorInteractionRaycaster.ray)
|
|
909
|
-
.applyMatrix4(doorOpeningInverseMatrix)
|
|
910
|
-
|
|
911
|
-
const localOpeningHit = doorOpeningLocalRay.intersectBox(
|
|
912
|
-
doorOpeningBox,
|
|
913
|
-
doorOpeningLocalHit,
|
|
914
|
-
)
|
|
915
|
-
if (!localOpeningHit) continue
|
|
916
|
-
|
|
917
|
-
doorOpeningWorldHit.copy(localOpeningHit).applyMatrix4(doorOpeningMatrix)
|
|
918
|
-
const openingHitDistance = doorOpeningWorldHit.distanceTo(
|
|
919
|
-
doorInteractionRaycaster.ray.origin,
|
|
920
|
-
)
|
|
921
|
-
|
|
922
|
-
if (
|
|
923
|
-
openingHitDistance <= DOOR_INTERACTION_DISTANCE &&
|
|
924
|
-
openingHitDistance < closestDistance
|
|
925
|
-
) {
|
|
926
|
-
closestDoorId = doorId as AnyNodeId
|
|
927
|
-
closestDistance = openingHitDistance
|
|
928
|
-
}
|
|
929
|
-
continue
|
|
930
|
-
}
|
|
931
|
-
|
|
932
|
-
const hingeX = node.hingesSide === 'right' ? leafW / 2 : -leafW / 2
|
|
933
|
-
const swingDirectionSign = node.swingDirection === 'inward' ? 1 : -1
|
|
934
|
-
const hingeDirectionSign = node.hingesSide === 'right' ? 1 : -1
|
|
935
|
-
const currentSwingAngle =
|
|
936
|
-
useInteractive.getState().doors[doorId as AnyNodeId]?.swingAngle ?? node.swingAngle ?? 0
|
|
937
|
-
const clampedSwingAngle = Math.max(0, Math.min(DOOR_SWING_OPEN_ANGLE, currentSwingAngle))
|
|
938
|
-
const leafSwingRotation = clampedSwingAngle * swingDirectionSign * hingeDirectionSign
|
|
939
|
-
|
|
940
|
-
doorLeafMatrix
|
|
941
|
-
.copy(object.matrixWorld)
|
|
942
|
-
.multiply(new Matrix4().makeTranslation(hingeX, 0, 0))
|
|
943
|
-
.multiply(new Matrix4().makeRotationY(leafSwingRotation))
|
|
944
|
-
.multiply(new Matrix4().makeTranslation(-hingeX, leafCenterY, 0))
|
|
945
|
-
doorLeafInverseMatrix.copy(doorLeafMatrix).invert()
|
|
946
|
-
doorLeafBox.min.set(-leafW / 2, -leafH / 2, -DOOR_LEAF_INTERACTION_DEPTH / 2)
|
|
947
|
-
doorLeafBox.max.set(leafW / 2, leafH / 2, DOOR_LEAF_INTERACTION_DEPTH / 2)
|
|
948
|
-
doorLeafLocalRay.copy(doorInteractionRaycaster.ray).applyMatrix4(doorLeafInverseMatrix)
|
|
949
|
-
|
|
950
|
-
const localHit = doorLeafLocalRay.intersectBox(doorLeafBox, doorLeafLocalHit)
|
|
951
|
-
if (!localHit) continue
|
|
952
|
-
|
|
953
|
-
doorLeafWorldHit.copy(localHit).applyMatrix4(doorLeafMatrix)
|
|
954
|
-
const hitDistance = doorLeafWorldHit.distanceTo(doorInteractionRaycaster.ray.origin)
|
|
955
|
-
|
|
956
|
-
if (hitDistance <= DOOR_INTERACTION_DISTANCE && hitDistance < closestDistance) {
|
|
957
|
-
closestDoorId = doorId as AnyNodeId
|
|
958
|
-
closestDistance = hitDistance
|
|
959
|
-
}
|
|
960
|
-
}
|
|
961
|
-
|
|
962
|
-
return closestDoorId
|
|
963
|
-
}, [camera])
|
|
964
|
-
|
|
965
|
-
const resolveInteractableWindowId = useCallback((): AnyNodeId | null => {
|
|
966
|
-
const nodes = useScene.getState().nodes
|
|
967
|
-
camera.updateMatrixWorld(true)
|
|
968
|
-
windowInteractionRaycaster.setFromCamera(centerScreenPoint, camera)
|
|
969
|
-
|
|
970
|
-
let closestWindowId: AnyNodeId | null = null
|
|
971
|
-
let closestDistance = DOOR_INTERACTION_DISTANCE
|
|
972
|
-
|
|
973
|
-
for (const windowId of sceneRegistry.byType.window!) {
|
|
974
|
-
const node = nodes[windowId as AnyNodeId]
|
|
975
|
-
if (node?.type !== 'window') continue
|
|
976
|
-
if (node.openingKind === 'opening') continue
|
|
977
|
-
if (!hasNativeLeaf(node)) continue
|
|
978
|
-
if (!isOperableWindowType(node.windowType)) continue
|
|
979
|
-
|
|
980
|
-
const object = sceneRegistry.nodes.get(windowId)
|
|
981
|
-
if (!object) continue
|
|
982
|
-
|
|
983
|
-
const hit = windowInteractionRaycaster
|
|
984
|
-
.intersectObject(object, true)
|
|
985
|
-
.find((intersection) => intersection.distance <= DOOR_INTERACTION_DISTANCE)
|
|
986
|
-
if (!(hit && hit.distance < closestDistance)) continue
|
|
987
|
-
|
|
988
|
-
closestWindowId = windowId as AnyNodeId
|
|
989
|
-
closestDistance = hit.distance
|
|
990
|
-
}
|
|
991
|
-
|
|
992
|
-
return closestWindowId
|
|
993
|
-
}, [camera])
|
|
994
|
-
|
|
995
|
-
const resolveInteractableElevatorTarget =
|
|
996
|
-
useCallback((): FirstPersonInteractableTarget | null => {
|
|
997
|
-
const nodes = useScene.getState().nodes
|
|
998
|
-
camera.updateMatrixWorld(true)
|
|
999
|
-
elevatorInteractionRaycaster.setFromCamera(centerScreenPoint, camera)
|
|
1000
|
-
|
|
1001
|
-
let closestTarget: FirstPersonInteractableTarget | null = null
|
|
1002
|
-
let closestDistance = DOOR_INTERACTION_DISTANCE
|
|
1003
|
-
|
|
1004
|
-
for (const elevatorId of sceneRegistry.byType.elevator!) {
|
|
1005
|
-
const typedElevatorId = elevatorId as AnyNodeId
|
|
1006
|
-
const node = nodes[typedElevatorId]
|
|
1007
|
-
if (node?.type !== 'elevator') continue
|
|
1008
|
-
|
|
1009
|
-
const object = sceneRegistry.nodes.get(typedElevatorId)
|
|
1010
|
-
if (!object) continue
|
|
1011
|
-
|
|
1012
|
-
const runtime = useInteractive.getState().elevators[typedElevatorId]
|
|
1013
|
-
object.updateWorldMatrix(true, true)
|
|
1014
|
-
if (runtime) {
|
|
1015
|
-
elevatorLocalEyePosition.copy(camera.position)
|
|
1016
|
-
object.worldToLocal(elevatorLocalEyePosition)
|
|
1017
|
-
}
|
|
1018
|
-
const canUseCabButtons =
|
|
1019
|
-
runtime && isInsideElevatorCab(node, runtime, elevatorLocalEyePosition)
|
|
1020
|
-
|
|
1021
|
-
const intersections = elevatorInteractionRaycaster.intersectObject(object, true)
|
|
1022
|
-
for (const intersection of intersections) {
|
|
1023
|
-
if (intersection.distance > closestDistance) continue
|
|
1024
|
-
|
|
1025
|
-
const target = resolveElevatorButtonTarget(intersection.object)
|
|
1026
|
-
if (!target || target.elevatorId !== elevatorId) continue
|
|
1027
|
-
if (target.action === 'request-level') {
|
|
1028
|
-
if (!target.levelId || nodes[target.levelId]?.type !== 'level') continue
|
|
1029
|
-
}
|
|
1030
|
-
if (target.buttonKind === 'cab' && !canUseCabButtons) continue
|
|
1031
|
-
|
|
1032
|
-
closestTarget = {
|
|
1033
|
-
action: target.action,
|
|
1034
|
-
buttonKind: target.buttonKind,
|
|
1035
|
-
id: target.elevatorId,
|
|
1036
|
-
levelId: target.levelId,
|
|
1037
|
-
type: 'elevator',
|
|
1038
|
-
}
|
|
1039
|
-
closestDistance = intersection.distance
|
|
1040
|
-
}
|
|
1041
|
-
}
|
|
1042
|
-
|
|
1043
|
-
return closestTarget
|
|
1044
|
-
}, [camera])
|
|
1045
|
-
|
|
1046
|
-
const resolveInteractableTarget = useCallback((): FirstPersonInteractableTarget | null => {
|
|
1047
|
-
const elevatorTarget = resolveInteractableElevatorTarget()
|
|
1048
|
-
if (elevatorTarget) return elevatorTarget
|
|
1049
|
-
|
|
1050
|
-
const doorId = resolveInteractableDoorId()
|
|
1051
|
-
if (doorId) return { id: doorId, type: 'door' }
|
|
1052
|
-
|
|
1053
|
-
const windowId = resolveInteractableWindowId()
|
|
1054
|
-
if (windowId) return { id: windowId, type: 'window' }
|
|
1055
|
-
|
|
1056
|
-
camera.updateMatrixWorld(true)
|
|
1057
|
-
proceduralInteractionRaycaster.setFromCamera(centerScreenPoint, camera)
|
|
1058
|
-
proceduralInteractionRaycaster.far = DOOR_INTERACTION_DISTANCE
|
|
1059
|
-
let closest: FirstPersonInteractableTarget | null = null
|
|
1060
|
-
let closestDistance = DOOR_INTERACTION_DISTANCE
|
|
1061
|
-
const nodes = useScene.getState().nodes
|
|
1062
|
-
const covered = new Set<string>()
|
|
1063
|
-
for (const rawId of sceneRegistry.byType['procedural-item'] ?? []) {
|
|
1064
|
-
covered.add(rawId)
|
|
1065
|
-
const id = rawId as AnyNodeId
|
|
1066
|
-
const node = nodes[id]
|
|
1067
|
-
if (node?.type !== 'procedural-item') continue
|
|
1068
|
-
const procedural = node as ProceduralItemNode
|
|
1069
|
-
if (
|
|
1070
|
-
!operableParts(procedural.recipe).length &&
|
|
1071
|
-
!procedural.recipe.parts.some((part) => part.light)
|
|
1072
|
-
)
|
|
1073
|
-
continue
|
|
1074
|
-
const object = sceneRegistry.nodes.get(id)
|
|
1075
|
-
if (!object) continue
|
|
1076
|
-
for (const hit of proceduralInteractionRaycaster.intersectObject(object, true)) {
|
|
1077
|
-
if (hit.distance >= closestDistance) break
|
|
1078
|
-
// Skip what is not drawn: a joint tree's inactive rest or joint meshes.
|
|
1079
|
-
let drawn = true
|
|
1080
|
-
for (let at: Object3D | null = hit.object; at && at !== object; at = at.parent)
|
|
1081
|
-
if (!at.visible) drawn = false
|
|
1082
|
-
if (!drawn) continue
|
|
1083
|
-
let ancestor: Object3D | null = hit.object
|
|
1084
|
-
while (ancestor && ancestor !== object && !ancestor.userData.proceduralMotion)
|
|
1085
|
-
ancestor = ancestor.parent
|
|
1086
|
-
let motion = ancestor?.userData.proceduralMotion as
|
|
1087
|
-
| { nodeId?: string; partId?: string; kind?: 'hinge' | 'slide' | 'spin' }
|
|
1088
|
-
| undefined
|
|
1089
|
-
// An idle joint tree draws one merged mesh per slot; its face ranges name the part.
|
|
1090
|
-
const ranges = hit.object.userData.proceduralRanges as
|
|
1091
|
-
| { end: number; partId: string }[]
|
|
1092
|
-
| undefined
|
|
1093
|
-
if (!motion && ranges && hit.faceIndex != null) {
|
|
1094
|
-
const face = hit.faceIndex
|
|
1095
|
-
const partId = ranges.find((range) => face < range.end)?.partId
|
|
1096
|
-
const part = partId ? operablePartFor(procedural.recipe, partId) : undefined
|
|
1097
|
-
if (part) motion = { nodeId: id, partId: part.id, kind: part.kind }
|
|
1098
|
-
}
|
|
1099
|
-
closest =
|
|
1100
|
-
motion?.nodeId === id && motion.partId
|
|
1101
|
-
? { id, partId: motion.partId, kind: motion.kind, type: 'procedural' }
|
|
1102
|
-
: { id, type: 'procedural' }
|
|
1103
|
-
closestDistance = hit.distance
|
|
1104
|
-
break
|
|
1105
|
-
}
|
|
1106
|
-
}
|
|
1107
|
-
for (const rawId of sceneRegistry.byType.item ?? []) {
|
|
1108
|
-
covered.add(rawId)
|
|
1109
|
-
const id = rawId as AnyNodeId
|
|
1110
|
-
const node = nodes[id]
|
|
1111
|
-
if (
|
|
1112
|
-
node?.type !== 'item' ||
|
|
1113
|
-
!node.asset.interactive?.controls.some((control) => control.kind === 'toggle')
|
|
1114
|
-
)
|
|
1115
|
-
continue
|
|
1116
|
-
const object = sceneRegistry.nodes.get(id)
|
|
1117
|
-
if (!object) continue
|
|
1118
|
-
const hit = proceduralInteractionRaycaster.intersectObject(object, true)[0]
|
|
1119
|
-
if (hit && hit.distance < closestDistance) {
|
|
1120
|
-
closest = { id, type: 'item' }
|
|
1121
|
-
closestDistance = hit.distance
|
|
1122
|
-
}
|
|
1123
|
-
}
|
|
1124
|
-
// Doors and windows have their own walkthrough targets (resolveInteractable*Id), except those
|
|
1125
|
-
// built from a script: their clips run through the mechanism target below.
|
|
1126
|
-
for (const id of nativeOpeningIds()) covered.add(id)
|
|
1127
|
-
for (const id of mechanismTargetIds(covered)) {
|
|
1128
|
-
const object = sceneRegistry.nodes.get(id)
|
|
1129
|
-
const hit = object && proceduralInteractionRaycaster.intersectObject(object, true)[0]
|
|
1130
|
-
if (hit && hit.distance < closestDistance) {
|
|
1131
|
-
closest = { id, type: 'mechanism' }
|
|
1132
|
-
closestDistance = hit.distance
|
|
1133
|
-
}
|
|
1134
|
-
}
|
|
1135
|
-
if (!closest) {
|
|
1136
|
-
const selectedId = useViewer.getState().selection.selectedIds.at(-1) as AnyNodeId | undefined
|
|
1137
|
-
const selected = selectedId ? nodes[selectedId] : undefined
|
|
1138
|
-
if (
|
|
1139
|
-
selected?.type === 'item' &&
|
|
1140
|
-
selected.asset.interactive?.controls.some((control) => control.kind === 'toggle')
|
|
1141
|
-
)
|
|
1142
|
-
return { id: selected.id, type: 'item' }
|
|
1143
|
-
}
|
|
1144
|
-
if (closest) return closest
|
|
1145
|
-
|
|
1146
|
-
return null
|
|
1147
|
-
}, [
|
|
1148
|
-
camera,
|
|
1149
|
-
resolveInteractableDoorId,
|
|
1150
|
-
resolveInteractableElevatorTarget,
|
|
1151
|
-
resolveInteractableWindowId,
|
|
1152
|
-
])
|
|
1153
|
-
|
|
1154
|
-
const toggleInteractableTarget = useCallback(() => {
|
|
1155
|
-
// Drone is a camera, not an avatar: the click that re-acquires pointer lock
|
|
1156
|
-
// must not swing a door open under the shot being framed. (In capture
|
|
1157
|
-
// mode's walk camera the CLICK path is gated at handleMouseDown — there a
|
|
1158
|
-
// locked-pointer click is the shutter — but E/R still open doors, so the
|
|
1159
|
-
// photographer can stage the shot.)
|
|
1160
|
-
if (isDroneMode) return
|
|
1161
|
-
|
|
1162
|
-
const target = interactableTargetRef.current ?? resolveInteractableTarget()
|
|
1163
|
-
if (!target) return
|
|
1164
|
-
playTargetSound(target)
|
|
1165
|
-
if (target.type === 'mechanism') {
|
|
1166
|
-
const node = useScene.getState().nodes[target.id]
|
|
1167
|
-
if (node) toggleNodeMechanism(node)
|
|
1168
|
-
return
|
|
1169
|
-
}
|
|
1170
|
-
if (target.type === 'item') {
|
|
1171
|
-
const node = useScene.getState().nodes[target.id]
|
|
1172
|
-
if (node?.type === 'item' && node.asset.interactive)
|
|
1173
|
-
operateItem(target.id, node.asset.interactive)
|
|
1174
|
-
return
|
|
1175
|
-
}
|
|
1176
|
-
|
|
1177
|
-
if (target.type === 'procedural') {
|
|
1178
|
-
const node = useScene.getState().nodes[target.id]
|
|
1179
|
-
if (node?.type !== 'procedural-item') return
|
|
1180
|
-
const parts = operableParts((node as ProceduralItemNode).recipe).map((part) => part.id)
|
|
1181
|
-
const state = useInteractive.getState()
|
|
1182
|
-
if (parts.length === 0) {
|
|
1183
|
-
state.toggleProceduralLights(target.id)
|
|
1184
|
-
return
|
|
1185
|
-
}
|
|
1186
|
-
if (target.partId) state.toggleProceduralPart(target.id, target.partId)
|
|
1187
|
-
else
|
|
1188
|
-
state.setProceduralParts(
|
|
1189
|
-
target.id,
|
|
1190
|
-
parts,
|
|
1191
|
-
!parts.some((id) => state.procedural[target.id]?.parts[id]),
|
|
1192
|
-
)
|
|
1193
|
-
return
|
|
1194
|
-
}
|
|
1195
|
-
|
|
1196
|
-
if (target.type === 'elevator') {
|
|
1197
|
-
if (target.buttonKind === 'cab') {
|
|
1198
|
-
const state = useInteractive.getState().elevators[target.id]
|
|
1199
|
-
if (state) {
|
|
1200
|
-
ridingElevatorRef.current = {
|
|
1201
|
-
elevatorId: target.id,
|
|
1202
|
-
localControllerY: null,
|
|
1203
|
-
previousCarY: state.carY,
|
|
1204
|
-
}
|
|
1205
|
-
}
|
|
1206
|
-
}
|
|
1207
|
-
if (target.action === 'open-door') {
|
|
1208
|
-
openElevatorDoor(target.id)
|
|
1209
|
-
return
|
|
1210
|
-
}
|
|
1211
|
-
if (target.levelId) {
|
|
1212
|
-
const targetElevatorId =
|
|
1213
|
-
target.buttonKind === 'landing'
|
|
1214
|
-
? resolveElevatorDispatchTarget({
|
|
1215
|
-
elevators: useInteractive.getState().elevators,
|
|
1216
|
-
levelId: target.levelId,
|
|
1217
|
-
nodes: useScene.getState().nodes,
|
|
1218
|
-
requestedElevatorId: target.id,
|
|
1219
|
-
})
|
|
1220
|
-
: target.id
|
|
1221
|
-
requestElevatorLevel(targetElevatorId, target.levelId)
|
|
1222
|
-
}
|
|
1223
|
-
return
|
|
1224
|
-
}
|
|
1225
|
-
|
|
1226
|
-
if (target.type === 'window') {
|
|
1227
|
-
const node = useScene.getState().nodes[target.id]
|
|
1228
|
-
if (
|
|
1229
|
-
node?.type !== 'window' ||
|
|
1230
|
-
node.openingKind === 'opening' ||
|
|
1231
|
-
!isOperableWindowType(node.windowType)
|
|
1232
|
-
) {
|
|
1233
|
-
return
|
|
1234
|
-
}
|
|
1235
|
-
|
|
1236
|
-
toggleWindowOpenState(target.id, { persist: false })
|
|
1237
|
-
return
|
|
1238
|
-
}
|
|
1239
|
-
|
|
1240
|
-
const doorId = target.id
|
|
1241
|
-
|
|
1242
|
-
const node = useScene.getState().nodes[doorId]
|
|
1243
|
-
if (node?.type !== 'door' || node.openingKind === 'opening') return
|
|
1244
|
-
|
|
1245
|
-
toggleDoorOpenState(doorId, { persist: false })
|
|
1246
|
-
}, [isDroneMode, resolveInteractableTarget])
|
|
1247
|
-
|
|
1248
|
-
const closeInteractableTarget = useCallback(() => {
|
|
1249
|
-
if (isDroneMode) return
|
|
1250
|
-
|
|
1251
|
-
const target = interactableTargetRef.current ?? resolveInteractableTarget()
|
|
1252
|
-
if (!target) return
|
|
1253
|
-
|
|
1254
|
-
if (target.type === 'mechanism') {
|
|
1255
|
-
const node = useScene.getState().nodes[target.id]
|
|
1256
|
-
if (node) nodeMechanism(node)?.set(node, false)
|
|
1257
|
-
return
|
|
1258
|
-
}
|
|
1259
|
-
|
|
1260
|
-
if (target.type === 'procedural') {
|
|
1261
|
-
const node = useScene.getState().nodes[target.id]
|
|
1262
|
-
if (node?.type !== 'procedural-item') return
|
|
1263
|
-
const parts = target.partId
|
|
1264
|
-
? [target.partId]
|
|
1265
|
-
: operableParts((node as ProceduralItemNode).recipe).map((part) => part.id)
|
|
1266
|
-
useInteractive.getState().setProceduralParts(target.id, parts, false)
|
|
1267
|
-
return
|
|
1268
|
-
}
|
|
1269
|
-
|
|
1270
|
-
if (target.type === 'elevator') return
|
|
1271
|
-
|
|
1272
|
-
if (target.type === 'window') {
|
|
1273
|
-
const node = useScene.getState().nodes[target.id]
|
|
1274
|
-
if (
|
|
1275
|
-
node?.type !== 'window' ||
|
|
1276
|
-
node.openingKind === 'opening' ||
|
|
1277
|
-
!isOperableWindowType(node.windowType)
|
|
1278
|
-
) {
|
|
1279
|
-
return
|
|
1280
|
-
}
|
|
1281
|
-
|
|
1282
|
-
closeWindowOpenState(target.id, { persist: false })
|
|
1283
|
-
return
|
|
1284
|
-
}
|
|
1285
|
-
|
|
1286
|
-
const node = useScene.getState().nodes[target.id]
|
|
1287
|
-
if (node?.type !== 'door' || node.openingKind === 'opening') return
|
|
1288
|
-
|
|
1289
|
-
closeDoorOpenState(target.id, { persist: false })
|
|
1290
|
-
}, [isDroneMode, resolveInteractableTarget])
|
|
1291
|
-
|
|
1292
|
-
const placedSpawn = useMemo<FirstPersonSpawn | null>(() => {
|
|
1293
|
-
if (!(placedSpawnNode && placedSpawnNode.type === 'spawn')) return null
|
|
1294
|
-
|
|
1295
|
-
const spawnObject = sceneRegistry.nodes.get(placedSpawnNode.id)
|
|
1296
|
-
if (spawnObject) {
|
|
1297
|
-
spawnObject.updateWorldMatrix(true, false)
|
|
1298
|
-
spawnObject.getWorldPosition(spawnWorldPosition)
|
|
1299
|
-
spawnWorldEuler.setFromRotationMatrix(spawnObject.matrixWorld, 'YXZ')
|
|
1300
|
-
|
|
1301
|
-
return {
|
|
1302
|
-
position: [
|
|
1303
|
-
spawnWorldPosition.x,
|
|
1304
|
-
spawnWorldPosition.y + FIRST_PERSON_SPAWN_EYE_HEIGHT,
|
|
1305
|
-
spawnWorldPosition.z,
|
|
1306
|
-
],
|
|
1307
|
-
yaw: spawnWorldEuler.y,
|
|
1308
|
-
}
|
|
1309
|
-
}
|
|
1310
|
-
|
|
1311
|
-
return {
|
|
1312
|
-
position: [
|
|
1313
|
-
placedSpawnNode.position[0],
|
|
1314
|
-
placedSpawnNode.position[1] + FIRST_PERSON_SPAWN_EYE_HEIGHT,
|
|
1315
|
-
placedSpawnNode.position[2],
|
|
1316
|
-
],
|
|
1317
|
-
yaw: placedSpawnNode.rotation,
|
|
1318
|
-
}
|
|
1319
|
-
}, [placedSpawnNode])
|
|
1320
|
-
|
|
1321
|
-
useEffect(() => {
|
|
1322
|
-
// Drone has no gravity, no floor and no collision, so the BVH collider world
|
|
1323
|
-
// (a full-scene traversal, rebuilt on every door/window animation) is pure
|
|
1324
|
-
// cost there. Switching modes disposes it and rebuilds on the way back.
|
|
1325
|
-
if (!isDroneMode) rebuildColliderWorld()
|
|
1326
|
-
|
|
1327
|
-
return () => {
|
|
1328
|
-
worldRef.current?.dispose()
|
|
1329
|
-
worldRef.current = null
|
|
1330
|
-
disposeElevatorColliderMeshes(elevatorColliderMeshesRef.current)
|
|
1331
|
-
elevatorColliderMeshesRef.current = []
|
|
1332
|
-
setElevatorColliderMeshes([])
|
|
1333
|
-
setWorld(null)
|
|
1334
|
-
}
|
|
1335
|
-
}, [isDroneMode, rebuildColliderWorld])
|
|
1336
|
-
|
|
1337
|
-
useEffect(() => {
|
|
1338
|
-
if (isDroneMode) return
|
|
1339
|
-
emitter.on('door:animation-completed', rebuildColliderWorld)
|
|
1340
|
-
emitter.on('window:animation-completed', rebuildColliderWorld)
|
|
1341
|
-
// A door built from a script opens by its model's clip, which reports no animation end.
|
|
1342
|
-
const stopWatching = watchScriptedDoors(rebuildColliderWorld)
|
|
1343
|
-
return () => {
|
|
1344
|
-
emitter.off('door:animation-completed', rebuildColliderWorld)
|
|
1345
|
-
emitter.off('window:animation-completed', rebuildColliderWorld)
|
|
1346
|
-
stopWatching()
|
|
1347
|
-
}
|
|
1348
|
-
}, [isDroneMode, rebuildColliderWorld])
|
|
1349
|
-
|
|
1350
|
-
// A walk session started before a drone detour would otherwise resume at its
|
|
1351
|
-
// original spawn; drop it so the next walk re-derives one.
|
|
1352
|
-
useEffect(() => {
|
|
1353
|
-
if (isDroneMode) setControllerStart(null)
|
|
1354
|
-
}, [isDroneMode])
|
|
1355
|
-
|
|
1356
|
-
// Drone picks up wherever the camera currently is — orbit pose or walk eye.
|
|
1357
|
-
useEffect(() => {
|
|
1358
|
-
if (!isDroneMode) return
|
|
1359
|
-
seedDroneLook(lookRef.current, camera.quaternion)
|
|
1360
|
-
droneVelocityRef.current.set(0, 0, 0)
|
|
1361
|
-
|
|
1362
|
-
// Interaction prompts belong to walk; drop whatever its frame left behind.
|
|
1363
|
-
if (useViewer.getState().hoveredId === interactableTargetRef.current?.id) {
|
|
1364
|
-
useViewer.getState().setHoveredId(null)
|
|
1365
|
-
}
|
|
1366
|
-
interactableTargetRef.current = null
|
|
1367
|
-
useFirstPersonHud.getState().reset()
|
|
1368
|
-
}, [camera, isDroneMode])
|
|
1369
|
-
|
|
1370
|
-
useEffect(() => {
|
|
1371
|
-
if (!world) return
|
|
1372
|
-
if (controllerStart) return
|
|
1373
|
-
|
|
1374
|
-
const spawn = placedSpawn ?? deriveFirstPersonSpawn(camera, world)
|
|
1375
|
-
const [x, y, z] = spawn.position
|
|
1376
|
-
lookRef.current.yaw = spawn.yaw
|
|
1377
|
-
lookRef.current.pitch = 0
|
|
1378
|
-
setControllerStart({
|
|
1379
|
-
position: [x, y - CONTROLLER_CENTER_FROM_EYE, z],
|
|
1380
|
-
yaw: spawn.yaw,
|
|
1381
|
-
})
|
|
1382
|
-
}, [camera, controllerStart, placedSpawn, world])
|
|
1383
|
-
|
|
1384
|
-
useEffect(() => {
|
|
1385
|
-
const canvas = gl.domElement
|
|
1386
|
-
focusFirstPersonCanvas(canvas)
|
|
1387
|
-
|
|
1388
|
-
const frame = window.requestAnimationFrame(() => focusFirstPersonCanvas(canvas))
|
|
1389
|
-
return () => window.cancelAnimationFrame(frame)
|
|
1390
|
-
}, [gl])
|
|
1391
|
-
|
|
1392
|
-
// The pointer-lock effect below must be mount-stable. Its cleanup exits
|
|
1393
|
-
// pointer lock, and `toggleInteractableTarget` is recreated whenever the
|
|
1394
|
-
// camera object changes — which happens right after entry when the
|
|
1395
|
-
// persisted orthographic mode swaps to perspective. If the async lock
|
|
1396
|
-
// grant lands before that re-run, the cleanup's exitPointerLock fires an
|
|
1397
|
-
// unlock the handler reads as "user left walkthrough", instantly
|
|
1398
|
-
// cancelling a fresh entry (and arming the browser's ~1.25s re-lock
|
|
1399
|
-
// cooldown, so the next presses fail too). Route the callback through a
|
|
1400
|
-
// ref so the effect deps stay `[gl]`.
|
|
1401
|
-
const toggleInteractableTargetRef = useRef(toggleInteractableTarget)
|
|
1402
|
-
useEffect(() => {
|
|
1403
|
-
toggleInteractableTargetRef.current = toggleInteractableTarget
|
|
1404
|
-
}, [toggleInteractableTarget])
|
|
1405
|
-
|
|
1406
|
-
useEffect(() => {
|
|
1407
|
-
const canvas = gl.domElement
|
|
1408
|
-
const handleMouseMove = (e: MouseEvent) => {
|
|
1409
|
-
if (document.pointerLockElement !== canvas) return
|
|
1410
|
-
// Shutter hold: the shot is rendering — a mouse twitch must not pan it.
|
|
1411
|
-
if (useEditor.getState().captureShutterHold) return
|
|
1412
|
-
|
|
1413
|
-
turnFirstPersonLook(
|
|
1414
|
-
lookRef.current,
|
|
1415
|
-
e.movementX,
|
|
1416
|
-
e.movementY,
|
|
1417
|
-
useEditor.getState().firstPersonMovementMode === 'drone' && droneSlowKeyRef.current
|
|
1418
|
-
? DRONE_SLOW_MULTIPLIER
|
|
1419
|
-
: 1,
|
|
1420
|
-
)
|
|
1421
|
-
}
|
|
1422
|
-
|
|
1423
|
-
const handleClick = (event: MouseEvent) => {
|
|
1424
|
-
const target = event.target
|
|
1425
|
-
if (!(target instanceof HTMLElement)) return
|
|
1426
|
-
if (!canvas.contains(target)) return
|
|
1427
|
-
if (document.pointerLockElement !== canvas) {
|
|
1428
|
-
canvas.requestPointerLock?.()
|
|
1429
|
-
}
|
|
1430
|
-
}
|
|
1431
|
-
|
|
1432
|
-
const handleMouseDown = (event: MouseEvent) => {
|
|
1433
|
-
if (document.pointerLockElement !== canvas) return
|
|
1434
|
-
if (event.button !== 0) return
|
|
1435
|
-
|
|
1436
|
-
// Capture mode: the locked-pointer click is the SHUTTER (the snapshot
|
|
1437
|
-
// overlay's window-capture listener already fired); doors stay on E/R.
|
|
1438
|
-
if (useEditor.getState().isCaptureMode) return
|
|
1439
|
-
|
|
1440
|
-
event.preventDefault()
|
|
1441
|
-
event.stopPropagation()
|
|
1442
|
-
toggleInteractableTargetRef.current()
|
|
1443
|
-
}
|
|
1444
|
-
|
|
1445
|
-
const handlePointerLockChange = () => {
|
|
1446
|
-
const isLocked = document.pointerLockElement === canvas
|
|
1447
|
-
if (isLocked) {
|
|
1448
|
-
hadPointerLockRef.current = true
|
|
1449
|
-
suspendRef.current = false
|
|
1450
|
-
useViewer.getState().setWalkthroughSuspended(false)
|
|
1451
|
-
return
|
|
1452
|
-
}
|
|
1453
|
-
|
|
1454
|
-
// Deliberately released (screenshot pause) — stay in first person;
|
|
1455
|
-
// clicking the canvas re-locks.
|
|
1456
|
-
if (suspendRef.current) return
|
|
1457
|
-
|
|
1458
|
-
// Capture mode: Esc (the browser's own unlock — no keydown reaches us)
|
|
1459
|
-
// acts like P. Dropping back to orbit would throw away the framed pose,
|
|
1460
|
-
// which reads as a crash to anyone who never noticed P.
|
|
1461
|
-
if (
|
|
1462
|
-
hadPointerLockRef.current &&
|
|
1463
|
-
useEditor.getState().isCaptureMode &&
|
|
1464
|
-
useEditor.getState().isFirstPersonMode
|
|
1465
|
-
) {
|
|
1466
|
-
suspendRef.current = true
|
|
1467
|
-
useViewer.getState().setWalkthroughSuspended(true)
|
|
1468
|
-
return
|
|
1469
|
-
}
|
|
1470
|
-
|
|
1471
|
-
if (hadPointerLockRef.current && useEditor.getState().isFirstPersonMode) {
|
|
1472
|
-
useEditor.getState().setFirstPersonMode(false)
|
|
1473
|
-
}
|
|
1474
|
-
}
|
|
1475
|
-
|
|
1476
|
-
handlePointerLockChange()
|
|
1477
|
-
document.addEventListener('mousemove', handleMouseMove)
|
|
1478
|
-
document.addEventListener('click', handleClick)
|
|
1479
|
-
document.addEventListener('mousedown', handleMouseDown, true)
|
|
1480
|
-
document.addEventListener('pointerlockchange', handlePointerLockChange)
|
|
1481
|
-
|
|
1482
|
-
return () => {
|
|
1483
|
-
document.removeEventListener('mousemove', handleMouseMove)
|
|
1484
|
-
document.removeEventListener('click', handleClick)
|
|
1485
|
-
document.removeEventListener('mousedown', handleMouseDown, true)
|
|
1486
|
-
document.removeEventListener('pointerlockchange', handlePointerLockChange)
|
|
1487
|
-
useViewer.getState().setWalkthroughSuspended(false)
|
|
1488
|
-
if (document.pointerLockElement === canvas) {
|
|
1489
|
-
document.exitPointerLock()
|
|
1490
|
-
}
|
|
1491
|
-
}
|
|
1492
|
-
}, [gl])
|
|
1493
|
-
|
|
1494
|
-
useEffect(() => {
|
|
1495
|
-
const canvas = gl.domElement
|
|
1496
|
-
|
|
1497
|
-
const applyMovementKey = (event: KeyboardEvent, active: boolean) => {
|
|
1498
|
-
if (isTypingTarget(event.target)) return false
|
|
1499
|
-
|
|
1500
|
-
const movement = getMovementInputForKey(event.code, active)
|
|
1501
|
-
if (!movement) return false
|
|
1502
|
-
|
|
1503
|
-
event.preventDefault()
|
|
1504
|
-
Object.assign(movementInputRef.current, movement)
|
|
1505
|
-
controllerRef.current?.setMovement(movement)
|
|
1506
|
-
return true
|
|
1507
|
-
}
|
|
1508
|
-
|
|
1509
|
-
const handleKeyDown = (event: KeyboardEvent) => {
|
|
1510
|
-
const handledMovement = applyMovementKey(event, true)
|
|
1511
|
-
if (handledMovement) return
|
|
1512
|
-
|
|
1513
|
-
if (isTypingTarget(event.target)) return
|
|
1514
|
-
|
|
1515
|
-
if (event.code === 'ControlLeft' || event.code === 'ControlRight') {
|
|
1516
|
-
// While paused (P), crouch is frozen as-is — ⌃⇧⌘4 (clipboard
|
|
1517
|
-
// screenshot) must not toggle it under the user.
|
|
1518
|
-
if (!suspendRef.current) crouchKeyRef.current = true
|
|
1519
|
-
} else if (event.code === 'KeyQ') {
|
|
1520
|
-
// Drone descend. Space (already bound to jump) and E are the matching ascend.
|
|
1521
|
-
event.preventDefault()
|
|
1522
|
-
event.stopPropagation()
|
|
1523
|
-
if (!suspendRef.current) droneDescendKeyRef.current = true
|
|
1524
|
-
} else if (event.code === 'KeyE' && isDroneMode) {
|
|
1525
|
-
event.preventDefault()
|
|
1526
|
-
event.stopPropagation()
|
|
1527
|
-
if (!suspendRef.current) droneAscendKeyRef.current = true
|
|
1528
|
-
} else if ((event.code === 'AltLeft' || event.code === 'AltRight') && isDroneMode) {
|
|
1529
|
-
event.preventDefault()
|
|
1530
|
-
event.stopPropagation()
|
|
1531
|
-
if (!suspendRef.current) droneSlowKeyRef.current = true
|
|
1532
|
-
} else if (event.code === 'Escape') {
|
|
1533
|
-
event.preventDefault()
|
|
1534
|
-
event.stopPropagation()
|
|
1535
|
-
// Capture mode, first Esc frees the cursor (see handlePointerLockChange
|
|
1536
|
-
// — while locked the browser usually unlocks without delivering the
|
|
1537
|
-
// keydown); with the cursor already free, Esc cancels the snapshot
|
|
1538
|
-
// (setCaptureMode(false) also lands the camera back on orbit).
|
|
1539
|
-
if (useEditor.getState().isCaptureMode) {
|
|
1540
|
-
if (document.pointerLockElement === canvas) {
|
|
1541
|
-
suspendRef.current = true
|
|
1542
|
-
useViewer.getState().setWalkthroughSuspended(true)
|
|
1543
|
-
document.exitPointerLock()
|
|
1544
|
-
} else {
|
|
1545
|
-
useEditor.getState().setCaptureMode(false)
|
|
1546
|
-
}
|
|
1547
|
-
return
|
|
1548
|
-
}
|
|
1549
|
-
if (document.pointerLockElement === canvas) {
|
|
1550
|
-
document.exitPointerLock()
|
|
1551
|
-
}
|
|
1552
|
-
useEditor.getState().setFirstPersonMode(false)
|
|
1553
|
-
} else if (event.code === 'KeyE' || event.code === 'KeyR') {
|
|
1554
|
-
event.preventDefault()
|
|
1555
|
-
event.stopPropagation()
|
|
1556
|
-
toggleInteractableTarget()
|
|
1557
|
-
} else if (event.code === 'KeyT') {
|
|
1558
|
-
event.preventDefault()
|
|
1559
|
-
event.stopPropagation()
|
|
1560
|
-
closeInteractableTarget()
|
|
1561
|
-
} else if (event.code === 'KeyP') {
|
|
1562
|
-
// P toggles a cursor pause (advertised in the HUD): frees the pointer
|
|
1563
|
-
// without leaving first person — e.g. for an OS screenshot, which
|
|
1564
|
-
// needs a movable cursor — and click or P resumes.
|
|
1565
|
-
event.preventDefault()
|
|
1566
|
-
event.stopPropagation()
|
|
1567
|
-
if (document.pointerLockElement === canvas) {
|
|
1568
|
-
suspendRef.current = true
|
|
1569
|
-
useViewer.getState().setWalkthroughSuspended(true)
|
|
1570
|
-
document.exitPointerLock()
|
|
1571
|
-
} else if (suspendRef.current) {
|
|
1572
|
-
const result = canvas.requestPointerLock?.() as Promise<void> | undefined
|
|
1573
|
-
if (result && typeof result.catch === 'function') result.catch(() => {})
|
|
1574
|
-
}
|
|
1575
|
-
}
|
|
1576
|
-
}
|
|
1577
|
-
|
|
1578
|
-
const handleKeyUp = (event: KeyboardEvent) => {
|
|
1579
|
-
if ((event.code === 'ControlLeft' || event.code === 'ControlRight') && !suspendRef.current) {
|
|
1580
|
-
crouchKeyRef.current = false
|
|
1581
|
-
}
|
|
1582
|
-
if (event.code === 'KeyQ' && !suspendRef.current) {
|
|
1583
|
-
droneDescendKeyRef.current = false
|
|
1584
|
-
}
|
|
1585
|
-
if (event.code === 'KeyE' && !suspendRef.current) {
|
|
1586
|
-
droneAscendKeyRef.current = false
|
|
1587
|
-
}
|
|
1588
|
-
if ((event.code === 'AltLeft' || event.code === 'AltRight') && !suspendRef.current) {
|
|
1589
|
-
droneSlowKeyRef.current = false
|
|
1590
|
-
}
|
|
1591
|
-
applyMovementKey(event, false)
|
|
1592
|
-
}
|
|
1593
|
-
|
|
1594
|
-
const handleBlur = () => {
|
|
1595
|
-
if (!suspendRef.current) {
|
|
1596
|
-
crouchKeyRef.current = false
|
|
1597
|
-
droneAscendKeyRef.current = false
|
|
1598
|
-
droneDescendKeyRef.current = false
|
|
1599
|
-
droneSlowKeyRef.current = false
|
|
1600
|
-
}
|
|
1601
|
-
}
|
|
1602
|
-
|
|
1603
|
-
document.addEventListener('keydown', handleKeyDown, true)
|
|
1604
|
-
document.addEventListener('keyup', handleKeyUp, true)
|
|
1605
|
-
window.addEventListener('blur', handleBlur)
|
|
1606
|
-
return () => {
|
|
1607
|
-
document.removeEventListener('keydown', handleKeyDown, true)
|
|
1608
|
-
document.removeEventListener('keyup', handleKeyUp, true)
|
|
1609
|
-
window.removeEventListener('blur', handleBlur)
|
|
1610
|
-
}
|
|
1611
|
-
}, [closeInteractableTarget, gl, isDroneMode, toggleInteractableTarget])
|
|
1612
|
-
|
|
1613
|
-
const syncElevatorColliderMeshes = useCallback(() => {
|
|
1614
|
-
const nodes = useScene.getState().nodes
|
|
1615
|
-
const interactive = useInteractive.getState()
|
|
1616
|
-
|
|
1617
|
-
for (const mesh of elevatorColliderMeshesRef.current) {
|
|
1618
|
-
const { doorWidth, dynamic, elevatorId, kind, levelId, localPosition, side } = mesh.userData
|
|
1619
|
-
const node = nodes[elevatorId]
|
|
1620
|
-
const runtime = interactive.elevators[elevatorId]
|
|
1621
|
-
const object = sceneRegistry.nodes.get(elevatorId)
|
|
1622
|
-
if (!(node?.type === 'elevator' && object && node.visible !== false)) {
|
|
1623
|
-
mesh.visible = false
|
|
1624
|
-
continue
|
|
1625
|
-
}
|
|
1626
|
-
|
|
1627
|
-
if (!dynamic && mesh.userData.matrixInitialized && mesh.visible) {
|
|
1628
|
-
continue
|
|
1629
|
-
}
|
|
1630
|
-
|
|
1631
|
-
let [localX, localY, localZ] = localPosition
|
|
1632
|
-
const isCabCollider = kind.startsWith('cab-')
|
|
1633
|
-
const isDoorCollider = kind === 'landing-door-left' || kind === 'landing-door-right'
|
|
1634
|
-
const isCabDoorGate = kind === 'cab-door-gate'
|
|
1635
|
-
const isLandingDoorGate = kind === 'landing-door-gate'
|
|
1636
|
-
|
|
1637
|
-
if (isCabCollider) {
|
|
1638
|
-
localY += runtime?.carY ?? 0
|
|
1639
|
-
}
|
|
1640
|
-
|
|
1641
|
-
if (kind === 'cab-door-left' || kind === 'cab-door-right') {
|
|
1642
|
-
if (!runtime) {
|
|
1643
|
-
mesh.visible = false
|
|
1644
|
-
continue
|
|
1645
|
-
}
|
|
1646
|
-
localX = getElevatorDoorLeafX(
|
|
1647
|
-
side ?? 'left',
|
|
1648
|
-
doorWidth ?? node.doorWidth,
|
|
1649
|
-
runtime.doorOpen,
|
|
1650
|
-
node.doorStyle,
|
|
1651
|
-
)
|
|
1652
|
-
mesh.visible = true
|
|
1653
|
-
} else if (isCabDoorGate) {
|
|
1654
|
-
if (!runtime) {
|
|
1655
|
-
mesh.visible = false
|
|
1656
|
-
continue
|
|
1657
|
-
}
|
|
1658
|
-
mesh.visible = runtime.doorOpen < ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD
|
|
1659
|
-
} else if (isDoorCollider) {
|
|
1660
|
-
const doorOpen = runtime?.currentLevelId === levelId ? (runtime?.doorOpen ?? 0) : 0
|
|
1661
|
-
localX = getElevatorDoorLeafX(
|
|
1662
|
-
side ?? 'left',
|
|
1663
|
-
doorWidth ?? node.doorWidth,
|
|
1664
|
-
doorOpen,
|
|
1665
|
-
node.doorStyle,
|
|
1666
|
-
)
|
|
1667
|
-
mesh.visible = true
|
|
1668
|
-
} else if (isLandingDoorGate) {
|
|
1669
|
-
const doorOpen = runtime?.currentLevelId === levelId ? (runtime?.doorOpen ?? 0) : 0
|
|
1670
|
-
mesh.visible = doorOpen < ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD
|
|
1671
|
-
} else {
|
|
1672
|
-
mesh.visible = true
|
|
1673
|
-
}
|
|
1674
|
-
|
|
1675
|
-
object.updateWorldMatrix(true, false)
|
|
1676
|
-
elevatorColliderMatrix.copy(object.matrixWorld)
|
|
1677
|
-
elevatorColliderMatrix.multiply(
|
|
1678
|
-
elevatorColliderLocalMatrix.makeTranslation(localX, localY, localZ),
|
|
1679
|
-
)
|
|
1680
|
-
mesh.matrix.copy(elevatorColliderMatrix)
|
|
1681
|
-
mesh.matrixWorld.copy(elevatorColliderMatrix)
|
|
1682
|
-
mesh.userData.matrixInitialized = true
|
|
1683
|
-
}
|
|
1684
|
-
}, [])
|
|
1685
|
-
|
|
1686
|
-
useFrame(() => {
|
|
1687
|
-
if (isDroneMode) return
|
|
1688
|
-
syncElevatorColliderMeshes()
|
|
1689
|
-
}, -1)
|
|
1690
|
-
|
|
1691
|
-
const syncElevatorRide = useCallback(
|
|
1692
|
-
(group: Group) => {
|
|
1693
|
-
const nodes = useScene.getState().nodes
|
|
1694
|
-
const interactive = useInteractive.getState()
|
|
1695
|
-
const activeRide = ridingElevatorRef.current
|
|
1696
|
-
let nextRide: {
|
|
1697
|
-
cabHeight: number
|
|
1698
|
-
cabCenterZ: number
|
|
1699
|
-
carY: number
|
|
1700
|
-
doorOpen: number
|
|
1701
|
-
elevatorId: AnyNodeId
|
|
1702
|
-
halfDepth: number
|
|
1703
|
-
halfWidth: number
|
|
1704
|
-
object: Object3D
|
|
1705
|
-
phase: NonNullable<(typeof interactive.elevators)[AnyNodeId]>['phase']
|
|
1706
|
-
} | null = null
|
|
1707
|
-
|
|
1708
|
-
const elevatorIds = activeRide
|
|
1709
|
-
? [
|
|
1710
|
-
activeRide.elevatorId,
|
|
1711
|
-
...Array.from(sceneRegistry.byType.elevator!).filter(
|
|
1712
|
-
(elevatorId) => elevatorId !== activeRide.elevatorId,
|
|
1713
|
-
),
|
|
1714
|
-
]
|
|
1715
|
-
: Array.from(sceneRegistry.byType.elevator!)
|
|
1716
|
-
|
|
1717
|
-
for (const elevatorId of elevatorIds) {
|
|
1718
|
-
const typedElevatorId = elevatorId as AnyNodeId
|
|
1719
|
-
const node = nodes[typedElevatorId]
|
|
1720
|
-
if (node?.type !== 'elevator') continue
|
|
1721
|
-
|
|
1722
|
-
const runtime = interactive.elevators[typedElevatorId]
|
|
1723
|
-
const object = sceneRegistry.nodes.get(typedElevatorId)
|
|
1724
|
-
if (!(runtime && object)) continue
|
|
1725
|
-
|
|
1726
|
-
object.updateWorldMatrix(true, true)
|
|
1727
|
-
elevatorLocalEyePosition.copy(camera.position)
|
|
1728
|
-
object.worldToLocal(elevatorLocalEyePosition)
|
|
1729
|
-
|
|
1730
|
-
const halfWidth = getElevatorCabWidth(node) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING
|
|
1731
|
-
const halfDepth = getElevatorCabDepth(node) / 2 - ELEVATOR_RIDE_HORIZONTAL_PADDING
|
|
1732
|
-
const cabCenterZ = getElevatorCabCenterZ(node)
|
|
1733
|
-
const cabHeight = Math.max(node.cabHeight, 1.4)
|
|
1734
|
-
const insideFootprint =
|
|
1735
|
-
Math.abs(elevatorLocalEyePosition.x) <= Math.max(halfWidth, 0.24) &&
|
|
1736
|
-
Math.abs(elevatorLocalEyePosition.z - cabCenterZ) <= Math.max(halfDepth, 0.24)
|
|
1737
|
-
const insideCabHeight =
|
|
1738
|
-
elevatorLocalEyePosition.y >= runtime.carY + 0.35 &&
|
|
1739
|
-
elevatorLocalEyePosition.y <= runtime.carY + cabHeight + 0.7
|
|
1740
|
-
const continuingRide =
|
|
1741
|
-
activeRide?.elevatorId === typedElevatorId &&
|
|
1742
|
-
insideFootprint &&
|
|
1743
|
-
elevatorLocalEyePosition.y >= runtime.carY - 0.2 &&
|
|
1744
|
-
elevatorLocalEyePosition.y <= runtime.carY + cabHeight + 1.25
|
|
1745
|
-
|
|
1746
|
-
if ((insideFootprint && insideCabHeight) || continuingRide) {
|
|
1747
|
-
nextRide = {
|
|
1748
|
-
cabHeight,
|
|
1749
|
-
cabCenterZ,
|
|
1750
|
-
carY: runtime.carY,
|
|
1751
|
-
doorOpen: runtime.doorOpen,
|
|
1752
|
-
elevatorId: typedElevatorId,
|
|
1753
|
-
halfDepth: Math.max(halfDepth, 0.24),
|
|
1754
|
-
halfWidth: Math.max(halfWidth, 0.24),
|
|
1755
|
-
object,
|
|
1756
|
-
phase: runtime.phase,
|
|
1757
|
-
}
|
|
1758
|
-
break
|
|
1759
|
-
}
|
|
1760
|
-
}
|
|
1761
|
-
|
|
1762
|
-
if (!nextRide) {
|
|
1763
|
-
ridingElevatorRef.current = null
|
|
1764
|
-
setElevatorRideLocked(false)
|
|
1765
|
-
return
|
|
1766
|
-
}
|
|
1767
|
-
|
|
1768
|
-
const previousCarY =
|
|
1769
|
-
activeRide?.elevatorId === nextRide.elevatorId ? activeRide.previousCarY : nextRide.carY
|
|
1770
|
-
const deltaY = nextRide.carY - previousCarY
|
|
1771
|
-
nextRide.object.updateWorldMatrix(true, true)
|
|
1772
|
-
elevatorLocalControllerPosition.copy(group.position)
|
|
1773
|
-
nextRide.object.worldToLocal(elevatorLocalControllerPosition)
|
|
1774
|
-
const localControllerY =
|
|
1775
|
-
activeRide?.elevatorId === nextRide.elevatorId && activeRide.localControllerY !== null
|
|
1776
|
-
? activeRide.localControllerY
|
|
1777
|
-
: elevatorLocalControllerPosition.y - nextRide.carY
|
|
1778
|
-
|
|
1779
|
-
if (Math.abs(deltaY) > 0.0001) {
|
|
1780
|
-
group.position.y += deltaY
|
|
1781
|
-
controllerRef.current?.resetLinVel()
|
|
1782
|
-
}
|
|
1783
|
-
|
|
1784
|
-
const shouldLockToCab =
|
|
1785
|
-
nextRide.phase === 'closing' ||
|
|
1786
|
-
nextRide.phase === 'moving' ||
|
|
1787
|
-
(nextRide.phase === 'opening' && nextRide.doorOpen < ELEVATOR_ENTRY_DOOR_OPEN_THRESHOLD)
|
|
1788
|
-
if (shouldLockToCab) {
|
|
1789
|
-
elevatorLocalControllerPosition.copy(group.position)
|
|
1790
|
-
nextRide.object.worldToLocal(elevatorLocalControllerPosition)
|
|
1791
|
-
const desiredLocalY = nextRide.carY + localControllerY
|
|
1792
|
-
if (Math.abs(elevatorLocalControllerPosition.y - desiredLocalY) > 0.002) {
|
|
1793
|
-
elevatorLocalControllerPosition.y = desiredLocalY
|
|
1794
|
-
elevatorWorldControllerPosition.copy(elevatorLocalControllerPosition)
|
|
1795
|
-
nextRide.object.localToWorld(elevatorWorldControllerPosition)
|
|
1796
|
-
group.position.y = elevatorWorldControllerPosition.y
|
|
1797
|
-
controllerRef.current?.resetLinVel()
|
|
1798
|
-
elevatorLocalControllerPosition.copy(group.position)
|
|
1799
|
-
nextRide.object.worldToLocal(elevatorLocalControllerPosition)
|
|
1800
|
-
}
|
|
1801
|
-
|
|
1802
|
-
const clampedX = Math.max(
|
|
1803
|
-
-nextRide.halfWidth,
|
|
1804
|
-
Math.min(nextRide.halfWidth, elevatorLocalControllerPosition.x),
|
|
1805
|
-
)
|
|
1806
|
-
const clampedZ = Math.max(
|
|
1807
|
-
nextRide.cabCenterZ - nextRide.halfDepth,
|
|
1808
|
-
Math.min(nextRide.cabCenterZ + nextRide.halfDepth, elevatorLocalControllerPosition.z),
|
|
1809
|
-
)
|
|
1810
|
-
|
|
1811
|
-
if (
|
|
1812
|
-
Math.abs(clampedX - elevatorLocalControllerPosition.x) > 0.0001 ||
|
|
1813
|
-
Math.abs(clampedZ - elevatorLocalControllerPosition.z) > 0.0001
|
|
1814
|
-
) {
|
|
1815
|
-
elevatorLocalControllerPosition.x = clampedX
|
|
1816
|
-
elevatorLocalControllerPosition.z = clampedZ
|
|
1817
|
-
elevatorWorldControllerPosition.copy(elevatorLocalControllerPosition)
|
|
1818
|
-
nextRide.object.localToWorld(elevatorWorldControllerPosition)
|
|
1819
|
-
group.position.x = elevatorWorldControllerPosition.x
|
|
1820
|
-
group.position.z = elevatorWorldControllerPosition.z
|
|
1821
|
-
controllerRef.current?.resetLinVel()
|
|
1822
|
-
}
|
|
1823
|
-
}
|
|
1824
|
-
|
|
1825
|
-
setElevatorRideLocked(shouldLockToCab)
|
|
1826
|
-
|
|
1827
|
-
ridingElevatorRef.current = {
|
|
1828
|
-
elevatorId: nextRide.elevatorId,
|
|
1829
|
-
localControllerY,
|
|
1830
|
-
previousCarY: nextRide.carY,
|
|
1831
|
-
}
|
|
1832
|
-
},
|
|
1833
|
-
[camera, setElevatorRideLocked],
|
|
1834
|
-
)
|
|
1835
|
-
|
|
1836
|
-
const hasStandingClearance = useCallback((position: Vector3) => {
|
|
1837
|
-
standClearanceRaycaster.set(position, standClearanceUp)
|
|
1838
|
-
standClearanceRaycaster.far = STAND_CLEARANCE
|
|
1839
|
-
const meshes: Mesh[] = []
|
|
1840
|
-
if (worldRef.current) meshes.push(worldRef.current.mesh)
|
|
1841
|
-
for (const mesh of elevatorColliderMeshesRef.current) {
|
|
1842
|
-
if (mesh.visible) meshes.push(mesh)
|
|
1843
|
-
}
|
|
1844
|
-
return standClearanceRaycaster.intersectObjects(meshes, false).length === 0
|
|
1845
|
-
}, [])
|
|
1846
|
-
|
|
1847
|
-
// Drone: the viewer's free flight (`stepDroneFlight`, also behind the viewer's
|
|
1848
|
-
// `DroneController`) fed from this controller's keys — WASD along the view
|
|
1849
|
-
// axes, Space or E rises, Q (or Ctrl) sinks, Shift boosts, Alt slows.
|
|
1850
|
-
useFrame((_, delta) => {
|
|
1851
|
-
if (!isDroneMode) return
|
|
1852
|
-
// Shutter hold: freeze the drone mid-air while the shot renders.
|
|
1853
|
-
if (useEditor.getState().captureShutterHold) return
|
|
1854
|
-
|
|
1855
|
-
const movement = movementInputRef.current
|
|
1856
|
-
droneInput.forward = movement.forward === true
|
|
1857
|
-
droneInput.backward = movement.backward === true
|
|
1858
|
-
droneInput.leftward = movement.leftward === true
|
|
1859
|
-
droneInput.rightward = movement.rightward === true
|
|
1860
|
-
droneInput.up = movement.jump === true || droneAscendKeyRef.current
|
|
1861
|
-
droneInput.down = droneDescendKeyRef.current || crouchKeyRef.current
|
|
1862
|
-
droneInput.boost = movement.run === true
|
|
1863
|
-
droneInput.slow = droneSlowKeyRef.current
|
|
1864
|
-
stepDroneFlight(camera, lookRef.current, droneInput, droneVelocityRef.current, delta)
|
|
1865
|
-
}, 2.5)
|
|
1866
|
-
|
|
1867
|
-
useFrame((_, delta) => {
|
|
1868
|
-
if (isDroneMode) return
|
|
1869
|
-
if (!controllerRef.current?.group) return
|
|
1870
|
-
|
|
1871
|
-
const group = controllerRef.current.group
|
|
1872
|
-
|
|
1873
|
-
// Crouch follows the held key; standing back up waits for headroom.
|
|
1874
|
-
// Frozen while the cursor pause is active.
|
|
1875
|
-
if (!suspendRef.current && crouchKeyRef.current !== crouched) {
|
|
1876
|
-
if (crouchKeyRef.current) setCrouched(true)
|
|
1877
|
-
else if (hasStandingClearance(group.position)) setCrouched(false)
|
|
1878
|
-
}
|
|
1879
|
-
const targetEyeOffset = crouched ? CROUCH_EYE_OFFSET : CAMERA_EYE_OFFSET
|
|
1880
|
-
eyeOffsetRef.current +=
|
|
1881
|
-
(targetEyeOffset - eyeOffsetRef.current) * Math.min(1, delta * EYE_LERP_SPEED)
|
|
1882
|
-
cameraOffset.set(0, eyeOffsetRef.current, 0)
|
|
1883
|
-
|
|
1884
|
-
// The site ground collider is effectively unbounded, but scenes without a
|
|
1885
|
-
// site node only have finite fallback floors — if the controller still ends
|
|
1886
|
-
// up below every collider it can never land, so put it back at the spawn.
|
|
1887
|
-
// Prefer the live spawn node over the mount-time start position so a spawn
|
|
1888
|
-
// moved mid-walkthrough doesn't respawn the player at stale coordinates.
|
|
1889
|
-
const worldBounds = worldRef.current?.bounds
|
|
1890
|
-
if (worldBounds && group.position.y < worldBounds.min.y - VOID_FALL_RESPAWN_DEPTH) {
|
|
1891
|
-
const respawnPosition = placedSpawn
|
|
1892
|
-
? [
|
|
1893
|
-
placedSpawn.position[0],
|
|
1894
|
-
placedSpawn.position[1] - CONTROLLER_CENTER_FROM_EYE,
|
|
1895
|
-
placedSpawn.position[2],
|
|
1896
|
-
]
|
|
1897
|
-
: controllerStart?.position
|
|
1898
|
-
if (respawnPosition) {
|
|
1899
|
-
group.position.set(respawnPosition[0]!, respawnPosition[1]!, respawnPosition[2]!)
|
|
1900
|
-
controllerRef.current.resetLinVel()
|
|
1901
|
-
ridingElevatorRef.current = null
|
|
1902
|
-
setElevatorRideLocked(false)
|
|
1903
|
-
}
|
|
1904
|
-
}
|
|
1905
|
-
|
|
1906
|
-
group.rotation.y = 0
|
|
1907
|
-
camera.position.copy(group.position).add(cameraOffset)
|
|
1908
|
-
cameraEuler.set(lookRef.current.pitch, lookRef.current.yaw, 0, 'YXZ')
|
|
1909
|
-
camera.quaternion.setFromEuler(cameraEuler)
|
|
1910
|
-
camera.updateMatrixWorld(true)
|
|
1911
|
-
syncElevatorRide(group)
|
|
1912
|
-
camera.position.copy(group.position).add(cameraOffset)
|
|
1913
|
-
camera.updateMatrixWorld(true)
|
|
1914
|
-
|
|
1915
|
-
const nextInteractableTarget = resolveInteractableTarget()
|
|
1916
|
-
const previousInteractableTarget = interactableTargetRef.current
|
|
1917
|
-
if (
|
|
1918
|
-
getInteractableTargetKey(previousInteractableTarget) !==
|
|
1919
|
-
getInteractableTargetKey(nextInteractableTarget)
|
|
1920
|
-
) {
|
|
1921
|
-
interactableTargetRef.current = nextInteractableTarget
|
|
1922
|
-
useViewer.getState().setHoveredId(nextInteractableTarget?.id ?? null)
|
|
1923
|
-
}
|
|
1924
|
-
|
|
1925
|
-
useFirstPersonHud.getState().setHud({
|
|
1926
|
-
interact: resolveHudInteract(nextInteractableTarget),
|
|
1927
|
-
})
|
|
1928
|
-
|
|
1929
|
-
hudLabelFrameRef.current += 1
|
|
1930
|
-
if (hudLabelFrameRef.current >= HUD_LABEL_SAMPLE_FRAMES) {
|
|
1931
|
-
hudLabelFrameRef.current = 0
|
|
1932
|
-
camera.getWorldPosition(hudWorldEyePosition)
|
|
1933
|
-
useFirstPersonHud.getState().setHud(resolveFirstPersonHudLabels(hudWorldEyePosition))
|
|
1934
|
-
}
|
|
1935
|
-
}, 2.5)
|
|
1936
|
-
|
|
1937
|
-
useEffect(() => {
|
|
1938
|
-
return () => {
|
|
1939
|
-
if (useViewer.getState().hoveredId === interactableTargetRef.current?.id) {
|
|
1940
|
-
useViewer.getState().setHoveredId(null)
|
|
1941
|
-
}
|
|
1942
|
-
}
|
|
1943
|
-
}, [])
|
|
1944
|
-
|
|
1945
|
-
const firstPersonColliderMeshes = useMemo(
|
|
1946
|
-
() => (world ? [world.mesh, ...elevatorColliderMeshes] : elevatorColliderMeshes),
|
|
1947
|
-
[world, elevatorColliderMeshes],
|
|
1948
|
-
)
|
|
1949
|
-
|
|
1950
|
-
if (isDroneMode || !world) {
|
|
1951
|
-
return null
|
|
1952
|
-
}
|
|
1953
|
-
|
|
1954
20
|
return (
|
|
1955
|
-
|
|
1956
|
-
{
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
colliderMeshes={firstPersonColliderMeshes}
|
|
1963
|
-
collisionCheckIteration={3}
|
|
1964
|
-
collisionPushBackDamping={0.1}
|
|
1965
|
-
collisionPushBackThreshold={0.001}
|
|
1966
|
-
debug={false}
|
|
1967
|
-
deceleration={30}
|
|
1968
|
-
delay={0}
|
|
1969
|
-
fallGravityFactor={4}
|
|
1970
|
-
floatCheckType="BOTH"
|
|
1971
|
-
floatDampingC={36}
|
|
1972
|
-
floatHeight={crouched ? CROUCH_FLOAT_HEIGHT : STAND_FLOAT_HEIGHT}
|
|
1973
|
-
floatPullBackHeight={0.35}
|
|
1974
|
-
floatSensorRadius={0.15}
|
|
1975
|
-
floatSpringK={1200}
|
|
1976
|
-
gravity={9.81}
|
|
1977
|
-
jumpVel={5}
|
|
1978
|
-
key="first-person-controller"
|
|
1979
|
-
maxRunSpeed={crouched ? CROUCH_RUN_SPEED : 5}
|
|
1980
|
-
maxSlope={1.2}
|
|
1981
|
-
maxWalkSpeed={crouched ? CROUCH_WALK_SPEED : 2}
|
|
1982
|
-
paused={isElevatorRideLocked || captureShutterHold}
|
|
1983
|
-
position={controllerStart.position}
|
|
1984
|
-
ref={setControllerApi}
|
|
1985
|
-
/>
|
|
1986
|
-
</KeyboardControls>
|
|
1987
|
-
)}
|
|
1988
|
-
</>
|
|
21
|
+
<LiveWalkthroughController
|
|
22
|
+
externalFov={captureOwnsFov}
|
|
23
|
+
onExit={onExit}
|
|
24
|
+
paused={paused}
|
|
25
|
+
pointerInteraction={!capture}
|
|
26
|
+
suspendOnUnlock={capture}
|
|
27
|
+
/>
|
|
1989
28
|
)
|
|
1990
29
|
}
|
|
1991
30
|
|